From 5393a8449471bb61e40e3cf0861ec5c531bec0f3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:36:53 +0200 Subject: [PATCH 001/155] build: PySide6-QtAds; bec_qtheme V1; dependencies updated and adjusted --- pyproject.toml | 18 ++++++++---- .../SpinnerWidget_started_linux.png | Bin 15265 -> 15755 bytes tests/unit_tests/conftest.py | 6 +++- tests/unit_tests/test_dark_mode_button.py | 26 ++++-------------- tests/unit_tests/test_stop_button.py | 4 --- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03fcaa33b..228d597b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,16 +15,23 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "bec_qthemes~=0.7, >=0.7", - "black~=25.0", # needed for bw-generate-cli - "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "bec_qthemes~=1.0, >=1.1.2", + "black~=25.0", # needed for bw-generate-cli + "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "ophyd_devices~=1.29, >=1.29.1", "pydantic~=2.0", "pyqtgraph==0.13.7", "PySide6==6.9.0", - "qtconsole~=5.5, >=5.5.1", # needed for jupyter console + "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", - "qtmonaco~=0.5", "thefuzz~=0.22", + "qtmonaco~=0.8, >=0.8.1", + "darkdetect~=0.8", + "PySide6-QtAds==4.4.0", + "pylsp-bec~=1.2", + "copier~=9.7", + "typer~=0.15", + "markdown~=3.9", ] @@ -41,7 +48,6 @@ dev = [ "pytest-cov~=6.1.1", "watchdog~=6.0", "pre_commit~=4.2", - ] [project.urls] diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png index bf2d9470f2e1a5b6b04536b76a0af27833247dfc..662bd4f75528ce7342282937a6d847a1ec99792e 100644 GIT binary patch literal 15755 zcmXY21yoec+rPV{G)Olhh_DFK4I+(nOP5MXcP%PNBaL)RNW&t%3P>uQOLuqYcYXig zo;_#p&Yk={{me|5x~e=r4mAz{0QiauGMWGYg#!R&0UHXIG}b8B0D!orqRb0zuk_tn z?-V0F@AX61{W8O`>s;OXMnhZ9vLq`-eUm!xpOesKd|ICVcI2xi^ms)$L>h|c!TS9t zs}w!!OU$=aX7G;3uihYWf5Txkw1#ndJrVYbWp!f_qqAG(CZ${NkL4AO-PJf-bjHd` zu5ZQEj@0#7Hl>sID*g)Zd<bJ75EVx_-%oAOOG_Fq&H-X5@vtpP1!@EL6L`r>s&Y zIdt5&?SenARY$8;`og9VHp0@O@IrJQ{_LkrD){fh%ZnRJHd0IMz=szq#dx--WIRwR-;dJfj1h3ttxwR?MO~pVruYN3K+V+Ek`%lDG_}R3XufMFN&KD!hN(FRo zmjMqMN1(Zw~s}*iK9|FX=;ykh}B=)Nz+B;l(QrnE0;DV>2g(6n0?1H zGr=}nkrJ|?rAghQ&jh~`5@P;4H*U3&OCHapA=bNby?+aP%l~~#-2aT#F^ziyRxbfd zrb7JdvAZh0>n>ACQTLsjlLae!N!gOzQN4F{FUmxUHd9H|6G1SS3v^0Iu-QN$OQ=U> zq_l#G3az_f=936>(ZWqigyp1aKM4MFhpx)>o)muinqS0y9!D3PISsE(oAO*?7_3Lp=}Q6DcS3n!6v4} z?}`SuI8H-)&0ZGh(v2QJZ?Y4$;CU0NYM3`tbx`}9y6P9PE1$(DW?dl*0u{>J<*K33 zukmflItlJ7iOC3EN1KXv@YvYh(B<~{ij%0eVada-sj$04S%2*< z_F6=$x?S0vE{%N|ZrubXYMTSK#UAecPRL>M)-tyBz-`{k-V8P^Nl!y5c=aULlGyHf zpuJ&Pz-DzSrvty?w4G>LiGdjAO&AbjZzX~_AKhrxT3jE9{7jL`$2sP+9@kr^l4zI4 zE}F)kJfWkVJe0a&HM&C=LCcUSAt2^&?{#%?x#^0V@u{L>5K&$&SRJzct( zfEZcK%0uSZr;+svkpF69{JF_s*SX_U4&<9%{<&w;$jX{#}~R<6vO2v z8uhL^v(Y;EDmr)(K}RycwZ^=@+xbw=lO}mWLX|(ao@3~BDt#r0+A({ZGJj<|2U+5Z zS@N_{^ELtQyI$l4$L+(4tc-L$#|nJq()T;IUe}EUyY4wRvrO<`%y9pe85#TY*c|aL zIf9HdyFJ9{MshvaQ|Z*OT!Z?1BQ}R^hQNZrC8_(4hOmY^w3$hx)3fs5#@opVbs78w z4Cs#=e6`L@A(7^9zAIxvJq@ouV)CC>Ber(w+$X-j*RRVD@BhOf9~!^@?JSda)>FjK z$;{=Mu`n6aGl#O);!@{{M%Fjqg5us&Bp*VSBM?w+~$n0*y>4X67`SKuC4y zqB@)Ti;Prg&LYH_uT?*vpGTfYcfRp&D+%pG*?PG1&y9|0n{HOb31*8CAy4f{dKo>xr| zT-vy75nujouQ+2u(`;!uLYJ1BtgBUykpMt{tQa6#&R4qI9q}Z6ZDGE?-PwijO%0mt z9G2ZPluO*o%sfLo(;`y`(rKq=#gB{zyw09im9R~GFKKesI*!&kwP(c)r-B&$y6Vb} zp%*!?5&T?JZ$8X#y?r!oyq(nSu3{-%mv{87r|2^6A+s8-_`To0*I8~^67}sFzKW;4 zt&Qb9iFyL|!tgTo;nCNFk6eU1+f;q6-M(W-sfGf4vx5!!Av2t!%NomqUGnsjVmBZh zN%h6ylMU5(x5->h7P8{F9a0)haOE&-Q8jv+yP>|f^OXIF*!p6nHiwtXO@j5$D&B5r z-TAZ+eKuwA+BxVx&_)FI(WI5m1$I``ObFO$+d?{nfM@txA*UNTuJ?jlWVga85R{l3^G4hXSIl-6}hev48h_#pDe+*@qBOVGjHvnGUjIn3=knZw=WJx zKXRSlm-s%`DvJW4ttfADb?p-nfdBgA(cud6iO=oK$P?c2-`lB2(dEAhjfTdjv~?Cx zXpGnkOKyH%TMTDem#1M)g@W3cqn-}-KJ%*%q(2@twN`Dk(QX8uL@nCzF0U2Tm)Tql zo~?FptDB)aISY%ET>f(G9mY@mz0K0YRA2&0uagBvaa(vI?vlumj_4_^`aQ~QUJVdZg+yCe)(N7}ChzX8s<@D%G zGE4lnFOS(K@U-_VRdTZRruwWAI#2dx%WQs9a%bPanCq5`aNlVDA~u5~t#|-R9tHeu zDl!-M%D(^1g)h_3*ZB-&YYCq};T(FS6nCJzWwP-=vhw`Z^i*m%{HRF}(RzP`Ai^&Q zl4XP+cM~ra?=3otkHjN59JLbf7BhD1?ce1@JPwcF4egp2J4d#**6xZ<+n(OLsC1zk zQ!j3yxG#Zq<$L{;QngHlru0I)EvzD#Y*-4%5O23p9q8)rPaW*uh2g*e zNV;#5!~OQppf=xfuHE!wl?;4_77rFeSV9tLQ0B#4uX>jAirch_)$g1PuYI}f#ChotjtFGrG=O|a(a@YjQTufp~2LANa~2mA7?n)-@O zqhDbJNF!pT@WYy&`&++#$yPssx;!gO>xCFZ{d}tpRbLC6JAygDV`FE2Z=dF?K!GVv zQEyvlH|F3z#5^|{s%@jWS4^aChW(((DVn&ib%!)VZcVN!{& zrA28*Slh`3gW7-_)vR-^Cxt?w11;tkKUCy9`*b@!ti zraQu4r%64vl*iWRho*+qH+zgBoyBk6u5jDLGJTR>g8aAMr0Zxnwa}>o=?n%kGt7kv z#eBE2OsNveOT&L`_l^_3H_WM8!OgF!vD0$y` z7#lj{vcKjYmDOs_uYw70NC{1y_u5+FYLd_2j!F#TnxE;BGru5*q-QdB#F*|~r}_IQ z`Cfe&DGInM<8YiC+!>34uxuR)VznKnjc8gY6|yrly}4{_`ySQv?t%!CF4s}Hk8-v5 zJTG$d2N6h~pX!ujHZunmY1Xx@=hlb6qK(vh4K@xKU6$={vCXEw#%>Ns%=zE=$u!=f z($XZHZ>f9b;GNvC^)^ZWqbC)FiW3vhiktVx>vmY-6-3Bq_a1&1%4+GUp$0t^A5*Ws z*1OMmC`RacVkCU+niD#&Gd=HsO}M$Ks9)cpCXAX2hf15EIFUdr(xb!k{M>K96JpaP zJv{4&~U)yzC{g)d92hy z^vpvab?h6D`3TN;1o>6d9cJ8$&-*${HG;l$NyThyW?>?mV*ug82y?8Y|B2IniXHuT z)*}D+6D8XsY0ZrsNWI~4lkY`@cVl!kBGf`JA6WNIK>R=&*Ti_n$Qs@6=<$DQ4L~)L zMQK|?gI(WPU5&)hiStvdwCd~S+^&`xpQOEPIC2E;x4H-Q&eG$yxGU@{8txJ0(?kzV zrE;R=qkQSN1Bx9VU0SGX-J{BN98K#-LBx)_Vjw%%haV~a-pt+wY+jYoB>A2FR`s}o znmmWC--&n8OFj}TyUC;5ZF3!SxwFpgzjy}cL|snDKcV+_)o;AZad2pBbH}*<1#uLX z_B(IL+!y0Ywr;tOD^#2R5fa~}u6jd;ByMj!U!NUDU>R%ed80}UpXIdhs>1^A))w@# zM6C789WF&hw)UFpNvQbFjEP$?6v3v|IZOW@weVXCDHfzrHDxmWpZ~M|q3edZc zDVWUZ-kzc+T#2Z9Kbk_l#B2kO7TYx`F3zSG1o!moeH0TxR$+_)ePD^x4UJ;MaZmv{ zw)&#&I;&&)5hK^XS!8)_bXOgZ^iT*2==m=jZ&Py!6VkGoFppHJN&0ACZ8du+uA0s_ z1Spc-dudGfO2P+JIwa0U1bu&%-v;X;Cj4%r@e^qSF-cpVwqNz<$>;AX(w*iw-j1_y z@>_8$>3@Bq?kIG%c<-s8G@rq-eYX{Tn$M+9QsTB-v8UhWT2M+SulC>jqKR8I8aB>V zF{p%hz}7M$9aXB!Ek$!x&0@s<4o>A)2;0+;RlhrmS+xvGmDV4iS!VDo{}zeHQeuyG z(Y?^YAnE7EXTfUP47w#*yIZ5$zgwj?tL(2i)M%5Z-S~7d@2;E1E9}b8w#wlc90Py^ zkI-S&=V%2Q$^c*2k*t})o`0zRDPd@zoI%|{hEtj-D%qvw}A93-}B+sAt5c)5TesCfTCs@31gw+ri; zD_!33!{~se9Oh*W-VsMgxV$j>fz{~EO8e&m)%g9X<^*@s?B>pikAZf_!z&T9?IVFV zgNuqhl(H2oXhIf~Sw1JBqwHr%r%{1E?bO2*Y`)<{S&im@IJaryxmuqKoo;5)g;DAj z&SQ{%940Uu@z}e))Y$y=Gfjl%&Yt4hAJV2ycKEhQ#Bs-7q17up@Mtc}(-0BACQWhJxeh+C0haN2tJ+LUw@1>+cx~gElmTD&>uwR>il|f+D_ri z;O#1O*_*h_Z`u$nF#u=SOm&&(G}c2jU73sDtQ|>8fse)73_bl8Lr;)AJH^XpQ$UK;00quc-lxmRf9;<>0X@Tij1WGO$g#EG;H-0$ z$h$cCHo)U?@0uBDRNMnI6tQW*QimN{c?gZCqeIJT+#0wxc-E|sV$CS*YOF>Je2wn= zQRA@^E4;0#?d?ES>*IUlz(sl$i9t9UMEqn(#|F39Q0vWd4u84;S649quc97 zbQKcEi0IQl{9oq?0oVGRg0;4}h1dN)4rW_HZ`G`QB)>e7r9fV*Mco~9eC*Ntr{^4! zYrWhhf!+ZJ8pF97Z=0jUzyRxmhLJq_SpvV3A5bANhnvpK_#Qn0Fos#W;s=Z2)u&w( z6?3CYBC@+&nL<6zifs9haLnXGuQSD-izQxQsTY)*2E0u!*YL(*>~d4N92RXVIqBea zde{3SaNt4G2MumnQY2xE$Jyq?#TAaM26JmeW44iNh}rY$ZRsqZ?cNoRHesrOmAV>} zS;Ct_edxug!b(M!Go6OZapUhaL8nnhxL6~|u4zq1T`c~`Uo*9sV_)rKxfHQMCo9tj z?yE&@S=R!s;qWw`@-gtu&^t#% zIzBTsId9TyoR+yC>037udHDAvfboeK!9P=7Fk4>TuEMFqFoXD2Ljzq<_H;C?suHf^ z=pkML89ya5)gS=3Qh^@pp#JP7TsHB0za#?-`p;mX8t>aT9QTIpHo zSqDw*dz3<-B9PhOVA=OKF$_uy66!oX(g5+f{u9rd0dT*+?yL6sp*&m%b5w1%GPU?E zK(G1f;sn1m{zrH!j!;AJbkEAiPc=MT#NHcazT>;7EFM~1lzhYopbIrJ~IyzJdK zs`_42%nmY4_JK&U_U&J?zPO5;-PXCFs&|FwXt?Tw{|rUnH&vN9ABhTE?AbbZZ>D_+ zu(niQ)~7fA1V`&p;qllvq3=Wu+JitqiSI>2=Hb`me`@N7NRjtE!9(Ej3ze32)`{`R zb)Vl^Tl8l;A?hAD6ak=uO}a+@oS$xX!xhhhke2pMZ|}`C^Ii7Ni=iI!!z%W$tP7nW zH+g8cXb;26?&Fue_!$LvO3P)gV zO9mPigtTExU22;A<~A8Kgthp$Gyy3ayshQj&1HA&svxo6@paPbEuj8*Z?e|@c339n zDsdYUY^(>9-GG=(IaEpgfD)Z5T%I(sh7liv*){JcsF^5UQ9e29NM|XXx!fp_$9-C6!7RLxX z zG;;4_;k>-w1{mkxx8z$}x-K206m8xt1h<0NRCntES#&YtHzB1qTk;hKKgXE?78RY2 z&z|K9tw)17i>?pxK2#B1;w@Mzb1$;Q#)<6zf1Rokc%K zsHA5C{ox3^np`~Y(YrGftpina$7e(X{X0qDV?RlG49mMC5+s;7i?n$nQ0!=|;Y;<6 z9>PYE%vFIxVPbpl#V)g`?x1X34AX1up95|V6b9aPl%~B1 z>-7f$*Bj_jigeszDu0a zHlR0-5BpB%y!&vm_?083=Bg0<-u(XS3g)V#v2j~Y0qb%TB-_ftNcVbtEbK)kbI1L( znkB6N=t9CDj1*sgZXjD4iR>i7|(Ut9D4X&}`jU3yGhbl~>$v z{gn80jDr|L0!m$rTTfK@v^!oPB%#S&xwM{j_kjRxy$g#9-BQ)%Arj2c4l?Pjub-RF zrN&EA0paNO?4ZM4D)XApOLFJ}9vdB;ez%0cI)>YWw9=7Q4WcY9Wx;w?;H zM=$I)-VQQJ_Y1%fambT}IP4ZhcCCIF^$NZThO8~v-v24Fx z{@veKpmQZUF35Ekg8|ocKE7HjWcOdQzW;H!ZMk04%s>=D2%5P-pDF$u|NW#l#+Cpc zQr=C)d%r+AzT#%2?`_7beauf_t%c-)PlSZ50-1W4qvy>9*0F)CKtlR_tD6C+(q;TD zowlU*Q$Q84R6qF-)1Z}PPkZ|k{LQa(c))6jK>mDyqSg`F<{`EMIoUtO=Pw>s;Z>5` zyzi3g2pkmOJISV{*4`n`-7a>7RD{0_qKwsP1H>Ull#g8>ZzbLohU2G z^0pCOik+B>`4mY`j|6jcJ1Tm|QKEu1SGdTDy_ub9w4;qh^D}0q_+YF9a3jk#K5SOL zjQ-TYa2;G_9xC#{P+7sY2G$dfAc>7_4blvdyV_gOO0uMp5V7So3@r;U>g5f@v8)j)2J?!4XV>U5A}R<5+q@h z9nO2yV39^320&p`;(}r+H}7e$&x za}R)kv%jb1UCVoU=-ftY6v%e1DsVT6m&;wHA;(p*-0=a>`xD=eCZ14Ua!~TQSZ9dY z<9;r9me3Y^;e2B=f|a!UkPaecP=vo}m^|^fxt6YKeZwi~yxkG(%Xj8@plSU14F){& z{pjC-n#V;f^feU(uLxARaieh8h>>cv zR7!iR)n$2z)XTR(_jeJ}$;L;|ad)u~JBD&#`=2ZNnc<#IPt@k-Iu9s-H&uPNMKgb3 ze9uztfb%HXdCK%u?_#?x_fbhSqT_27wdmY)&WcmK$u` zev=b#q#%tIUfr0}vh3liulU|K@CT6cp04UWo%B1?78(&$`7&Z2Mg| zy#a84FKIL5Aj|;jWhLTaHCPAP+#(QBha#Je#}-BCFaG>2jvZ%gArFAH3QavOyRuC0P3)=|gLi>xEGp+cxJY{`C>&#Y&*T@X>%CcEA2|}ioGHnh z>9?9XhJV>Y2e6qAGoLi@E`!l?*+Gy!#Lv%*hgQ*E%C>k&kF&Vv=*4tnn6phUwq`cz#^XTI4RE z^VWwKFncyGpreQNkZ=x6Obl9$wqFN*=D|Gp88Fe?QE08h&Vd9fg-RX?)cwHZ)vUw6 zVYURP)CJ`RR_2lf^Z*7cs>uqle(7Ff7-ty_K$9X+IOVIT#M{x^MDyycj3!VUXdcyG zzLnxeQLYJaM ziT%5+BAi)j$eC(>msz;Z-LoYy{tiYY0`|A`8~kc{@DL|qUK2pC!osP)kL zyejDeprVbSE#+T9nW8nE*qO%hv#^@>;Ax>)RU}xLb9!vcDuDr>U2ss~+5s8kvJ@X9 zwe+FdMCj&86c6?pg)r#*8zp*WDC>hAH0#%N7-Nge&?U9yV{)?$0_xbtDx}iaua$tT zZ|Kwj&I@+uMEW*1Ul-s!QYPNig9HhDVHn0S5050~0 zzVdciV=LRglMKnMRkcF0(TGzfLdtf*34wEgdou1!4r|sOZgSr&LGKHvp9~2ewmIrU zQ2#Xp8{3xQv$pNv^5X6??*a6l{+G68)>t6rHSD59;a%8f49*x%ZG2FVKz=k}Mb|Kc z6bXikGMCO+2PwYj{m;RfsSVp~55J^b{X>U*Sf1rC$_xVfWb?Tww<>TQ3ie^#N4R@x z55)w>a&!`ZV}N#+CD z{8ul~r0x{+=r%PXd6GzxdlkmR$vl9RgaSmYm$cT=D*eOzA}azc5o@6ULtF+-HTY^kZkpQ z`S%^5%u_r=cj|ZH6yE5ULX*ltpOJ6-{c0TUzxXR=wsL2sv^)*~C%u`<5rmKWd!m{U z5BBT7h{FxiO*)poVHk~SmHif5XyZ-<6X<3zc8`Ip$RS&up2KHRf8D?t{~M=#ksEpz ze8Ox7r37$TH!?p&8$^O5SUiE*;0sBCnkGoW4#{eVymMlL=S9LHNao?+@syAq!cBRk zrYrz32Ie>F#Xo%)_+v7(J*gzSy$L)t@wzgh6AEre{TGa~t{ zE|t+M%^yd@3DBXSRwR{!k=K3iApX=?{ty}9WRCM%!-xeSh#CaI1%Wt)+r9+=u`f^I zmv1{ z@J9j*&*>ZP63lT@)zARaNNtQi*pm!AuU;IFV8NxG(IGqXVZ=^He|fu{Q%E52UzQIm z10Q2>&o=o&YZ{K7e6msC%RweEC)qoZ590MGYu+ro)Prc<@_LI(?i0 zsYL}2e{HkN@a@l>w>u7LpaK<(qp$f_a^es{43a}tiTRxzQXTbO&}M}v0&8~R$7zv`X0U24+Ju=6Ai_?}7J>}k*$&EOre&2CW_qg);etoB%wy3f5;l)q7K;W{qd2;+oZq zWTXUKUihRd5gJ~||7K_&O$xRKRqlgL=aS-n$!qN-;LQ_owe;VJ9z9zol&GV_Qptgs z#oD8iUORRF!CIu5YV|ILp`l5sDiUf2(;+32IN=4oUf`O|V)L<{(tNpr83KJ>k2F4G z01?dM3OekSez0xQ+LCHKJfPtbC%|!NL31$%8}l6IsZ`N=2k5hH5|>n?P0qi$Jp2Yj z;{|mSMw};51ME1~M8RjbAA;*O`?*5Qjf=b!PzE@{A0=8C6n-7JhQN;=i;L;#SVAV( z-#scpyg2gU(ZuIn0Qu90tc5)wwreszW<2+iKN>JA3o+tMT?D`x1TPCk zgYndt%|8c;4VX?_Fg}i-05=B;krF8P`uJVvFBl)tPjNiZZAA}(siY}bV*r5SmKD?S zK+5wNK$kT5RsrDlEN+TP3H%*->5i5x;=&C|nSlbKB7vDy^(AJd-H{CYGXUr}yQ7-= z!v+7x!@?H-o_!@gfg+NEyOtF@3x%fqdk%I$^-D50d1F=PvJH22^F__f5&{Wm#pbPr z7%71vR!mxtQm?(tBEY&%H7l2IuNOWC4n*rj6B4(;`a*Dw|h1@!!3x>mJUq&dt z%vw-9fkKKo3qbn7B^8echL?%Izy#p@DwdKYKt4fWaiE-sFxvnw`eY0`az9@_>+b=;|p#;GM3)QNB!FkPED`Mzsj3^ zS>R*}X#u}q)v&IrhZS_*5v*Ty|FSCPAIHAyg&?1-EWOP*=}cdF2A{qDyHApYLFfo4uy%{sUnt7L)YwXGHKEvk{eLPg?Xl#}U2jw&g$~%ia z7b>LYTP3DX=7V2Jq^9ENom0sdc9{E(qa`^S|0;0(gWq<(JWD`Z_u`EGaLz z;nfL^SA7Q*$8py&5#?C%c0`n5r;@`5dK#q0gg#(yLAHU=OjaYmA_&Aefy(Bhw1mBL zAf=-DRf@tq`o9@GDK2fp3#bK-Ht#sP5`;=+Qp)u!id-5ID_65c9&a%JERIg`#UuL;uorC;s}A`|h>WbevV{D;YF4+d`YlQ*3X|AE5NgvKw-% z#A6Nt;Yrp8!{d2o+}#Hh4Vr@@P@NI&F`X!~h@oUOsn|H%3T+*&+0%Ta1!?0KHEMLx zXcb2oAluD{Uwjr`Cf{8qjpby^PyP_>NX52~ku@vUmEPWZq!##B0&@J&JHE^C+@fJE zTzxS@s8AhspR+xCE`;;}3X$KluFlqRKq0)Y!kf&8`hfp2>>ZHlN-Lilzuf;~CG73& zC=?=o`33Vupb{X)|EFj~$iT`;(;hG1E^{6A zUr#0SJsi>ofnw~d&Yv!oV$2?LC-GkM2XL9(#MyLYx^guLIMCIB=w-^`m6}|h%Sb3& zI6yA_NjhgGIofH17(W}pV@~p0$X4*Cg?MYiAnDVo?bR?8qPdX)2rx4!v~I}cJ@J$K zNvaG)c*)j|GK)}KJ_2ti2t`|Vte|1>bAH*pm3mL60YqGQs>NRHZDKdDh=4%}AGJ!R zpYErbLi+B~C7gxYrZ|h|Jd{$=RC1C2@&~%k7?niQBYZY`an?h*BvQ`|l5A4Tzu{a* zt9yaFh>vE=AA4Y;b(5hG1;%Y9B~|;_!FrJp*wQygRxZqM_M>QU3@2K7TVCWaSF>h5 zB)yT+UeqPa4M?fbe>Hd7_rFrur$IhW>$jDPo&*5yo79j|V@686>J!`wQb!OUD8%@x z-aNI?TJsBr*&J*nAt``A%DC-7Qv<7#=n*BJ23Q=c309WH$FvcCz(wW@M7YfgTRa!B zf~0VCIp>2%|0>k+lo;VUIw}(xA3MzeSCWW=&4!#3mV=X*-W*XeX11OLD8vTXk#3c~ zHhXjKI%c?x4LZ2;iI18MpR5fEZLEAG0ZO@7B0CBr(32UdShlYqbv7FPjN;5G-HOq_ z=-Rd^>LdyZLh0EH{*!@j66p?$BS9me(xE}~v5A-L06uAqCXN}?r(Cw2a2(-}c^U$& zkQ8&22Oo)IYc4j6w#JLA+@b`1kU&C0D|0R1us=pA0-uO@%a&&~Xz+p+Oi9y*al!T_ zB|j5Xhlbc>hX5HEdChEE^apzpX$|%QE8+j-b+!sKIo|h#;xOEJZ<%AN1mv&}7T)95)^hC7W`g zO?O!)8NW;-i`2CKKIdqvcSkA z3ZwmBl9A$_p!Ui?oe%Miae(2t5C4dz1w&9%)M z5fx9n*n!tu4VUItVWy5KoO<8!#xy~+4Xd)dR(w<1fSisa$S;iRsxUL=mk0C3Cm#Mk zLi->1)+2Fp*h}c~cnC39K-8*=#16=$(Ltqffsh`EnPE25G#2mR$y4gv566n|n~O$z^!Bq?Zs6!6y4Ac;7lK!S%) zWyHONFG$##wu{@srZC}C?zinf1Q+NKv4~gkZ^T654c#Z4x;nx}kN|mLKykX7nCVmb zwp%d<&vO-K_q2-ad}1PL@VspDW`x*SnpDZFrUxk^&^JWL;{~B;bIM}h4`ewTFEXS_ z7@s*n^30~GoS5P3oPF)G6Ye3u4r*SRYGRE2mBN8~G4bfUd7vA-s29CFiAX$adP=hq zRExb3+^7Aua~U%rewE|tsqVjH$GKLvY+RH$?SedsB3h0ZR`LLsZXqk$2dGVgXkRs6 zyMpLJfdM{v#92s?$Dxr3ggHn`YF+V@%aTyE4fWR~vx~V0{TPsRWrOvl$#0)rh`10+ zs4;`eBWOPsBpr0tXhO_Un{V^ojo&Kb74%iZM)-LBn~TNK*Mfn+EwJn7pFmC;bbpht zVNq@WkBlE*=!{)xqa8~F>!9yar4MV@e-V~kM0y9F#_O(KRvnzd(MN|0wb}QD7i=?f z>Ql-+?RE>T2+fM3!z+Om1Guuldd9L)l64{HUlP7bnX2CE8IGb|EEn*8#}^nX7~^@Q zMVz<~c+zL6hbAbtb4=wW}}%@H9@dWQ$NR7XH$;FNL_{nRbmum8KQ;5bhC zMw7ZB(f^kLQH#_I@TSKj6IKV5N1>vPtcJTdQiPVgz4}!kGIh#<*U*?`gH{S12a5g=zE0g2Sw@rKi_d_KWL&MnbDmN zs~u2$QLsGopGVw0Y3@0C*g5}Hd6oDAKJ>?=G38In!fvr~YZGeTB?ijA+UkA*TzPc2 zH|jtPF_MMZ_c~fgXY51@es^(aWtj9F^(8rpTI!Gyyio&&Z4LvgOr;onDwa9}6!s_t z%r%K=zmw~2cnQxIMmRdeC~mE~`N^~IM0V*3CP}niRStc8Wexs#Y@|Rq0eCHFt#3F2SUT|~mtXKhC_s};2bUXm z7Gj->utX(^Z_~;d-Sj=YZVMuj1(Mq?S*w0rSn26RMZLOwgF5Rs^Nel_p26rvgX}O< zh#aNq7PdQSg5epz&Iq5Z#)D;}DhHIaeP6YGUuNA0|KI)9;tjY7SkTEB@-H3I*s>jZ zkvg&(2Jh3k`l@NW5HM8LI&tF%us;gOjva5B;aVCFSWAOnMMnqc$8f~Gqa6xiJ$1G;4|3zA`yA+N<|I#W`f7k9Cm*dp8bi~z33K_$VfX@IGYKk=Cyv4 zubfN*9KQsZxwLi2N>at}(u-+5p7k3=bdnNM^atPSgoh5WCY#C%0^kvPwl5J4EHR|Z z-ruo_O%+0uqwai*$E)OLy1r`hDO3 zVDGszXP!CdIWcp;sjJH2V3K130Dz+)|5_6Opl|?yte``|UmB{EssKRrgTm`q+8*f# zi=IA|n;F|Dg~bhq>;{Dvy@}?%A?6fzpWEP^k;vFz`WeivE)x_!StVnn*b!8wDF=Uf z$JC41FcpmJ-M0)@{=_uPQ1ysPmBg517EiaNwJbN}--R}A~` zM!W7~`-C!UaazMW>w+Q*-2pEC_Ry3>hh*um@aJe23Oae&&(R1K5;oz_6Hv@`5}~1Q ziKC^9HI2%qzCa-cE_eR8aR2`hPI1=X0>0^A$(w&rq{tYPdwyFFqwo3~FF?-!hW_^T zz`*!&3p4Zd7ibM#zDVBGm-spL>;f_TCpYK%IZX<~N@n>tNIlQK37Zna89R>iro_p2 z$RaL->6_n`TCq)u5{rwLSqE=^+T+nAvbN5j)7F`KOJrB9n^r6{$Zx^U|Gfb-;Vo)~ z6f&2~aP{k}4;xNT=G%yl(YBtV)xRkxA6|1VWoMMAOBw1KOzG-PY0%ifDnHoE$SSFP zZQ+3n)4^kqa<%40+lq1@;^aaKM9`Q-L!N8kbspHoqvTz7e!pZSq6+I5O^h$M*4L(b z?}sKOfXuaJrpUXzi_tnOdU-ipG}PAnh#~IppTwrXPnX}IT%^8fDy3_#kdUAKyd)I8 zr?ObSn5`)E=SyiFbGk2<$*FXFAn;1OY>RySG9B1jyzK4O?;C`RQdWO(lXEMxMr9F@ z6Qj)Sv2qw1`Z;S&dZ(83>*!Jf6?AZJqlfihf(`ACm#X_mRH7oJgF0T5322Er#W)FV zsi#s+1jPb`KX6fYUrx-q)G*~~Cl=k1F7mT;Q%}C3@9K#c5Gx9zzm;duc*?(u>h|ic zj*)ke(C>+@E|B=>thL#x62@U&RtB401$_~N2_;Ev?@vjOUqDG!+qHg?sDGAYz$k{ zfGwHO*pzcRmSN~5dRnP|6f3?8eB43|VV>2JRd%$YD z*SAB4Bq0sRpwnTGi#b_7&uk1qE7%hG{q}ob2}#J@UcczBOvxVW6rZ+s$V3xgwLXYO z%;Pt+z&JTH`iwxW?%?Nfa|?-_O42`VnU`yGyyipaaQcZ^TuyMYfgTL#%rxqY8u9Ls zt8~oP1%Belq3=~CJ>L`t`!4!lrWN;-zEQEn7^R4$7UO-iJi1P6UYA3q4;Btlap+T;ox%iShOi8`qYkoa~nAmSE=J{c#Z|Lmoy4husK*FyR6zXwU zJM)_F>|p8FgH_k^KAHdBenus%XJ16b zr^2zeeqO)GWbAGIuOb(-XPeUX&Sll5?mqrU*A(LGzUx&PVkXP_&c1R77ki0(ny+y@ zppbpvz__`xcBID0$fj9Q?zDF!+0PyA46AEOZSMN!5-y0$9Uk?iDQVIdT;H1y_Z~m; zKC7Bd&>~n{E07fxv2p)t{O2#<$JFouq_HvHOcF(p?;eGZwKElMzaBVY=K8kwZ z|ItG~i^U@_)W<++bESud&;#3;S6Y#Gt`a=K^g? z@a?ahL}6(57ktDLH`jmO`%+KO(9pcQEUz<*A)ZFIlNVHOdUc!yRYn5^d}o3gGQ-qV zOXK_nJG>0et~yTJx3?J-C-yzhCXjy>*O=+85zzDz|0x&eBiJW7X64Kh9w^(wnGACS|rncEZ@hk)x3u zx#|#=nXP*V(%IQ*AC)aJtg2y1Xv@OZ_%HXs^{qEFIgXKZh3f>qlF(is47;KZe#T+cr|H9XlR(tk5ou(K^` z@Re!TDcSYtxs7;d*eWh@KNIk{zy4fBXE#KUW6@IsNfv8W8DJa<$yzD1qy)kmQ=TBQ6U2`5fF%J7b?s^I=4wPPE zE{Fl20S?s+q_`(n*j0H{gk{5bG1}#pmdExaj$8!MEa-$!x3?+Xc(|$-CU1NWc2gT` ztMS{V0{%mEZcaFD&|GEsUG~Tq7&I{w?H)PU{a(*OZKe7h7KAQWEw^;|BzbqoOJt)U zy--nbxNc-LgO9^Z3jM8r{v~&LdH!KybyXQD_#mub(#5(xVvk%qXw`Bc#8O|4X7{Og z&lhAHYsYBDovj)-n)$guqajide8i!sDDhpJ(qgNJJM;L|U|N!%@A+7Mn%)#alfIDB z*Cf^O`;Ch#&_tBHWuy`v8ck1V?5)2S;PtRVXH){8&EdfgJB{zgG@qRo z@Z*GGE(>@pflNo!GS+za;r1pn`?MmtTmEj{$#w4ll8c7=b z+&qL1jvQ=??mMOuBL>ChR$3m!&6elq9}L}Zd8r)?^{#dfN^vHvAXsmJZDVa-zth_k znpQ8SoI;jQ^%dXm4@(R=jZBQMgCPb@E;i>!FtLownJ9Is+ra)r-;FT~3p-{UHUN?? zmx$jOh3>XKd}hSz7*Y1v^_<B_oxRl^T1V&N)rEowmsFswFY48r)LC*L!A$+o zSA2GGymdo?!2V9hFC!42B&6TuGsnJ&!Kz?~@ zao(c2yeOb@T^coUz0$E4t57%eY9BqiznnN6;=79rfsXmljh#0pv1yr0s6$<)fbE&l z$8Y6F63!Kzg9Ed3jNgY%7Hhxln8g3oYkc!`=h%pgmp9w%;%nBK1#7Kr&U{7%pM4H@ zWwK0u+RH!m_bcUelk05=MZBovTdaM@GTzMWR=}7k;Og7F|DyAkmnS@4 zm^mLeB)@w+&Of9nO!KsUo9DOLrSsDnjfW1tt-$;9=-}YC*Vea)Y8f<(!$t>-JAz`3 z6_Sj=IM}k=8p2?kx+E6=>7Ca4i`p7gW`95vA93dFB6PY5`$SFdlqiyfwxMQkd+Sb- z<}rtGR93pT^FMOGI#~aV1c+&HPaGxO*E+R^9<2JHV_9!&?k}&{Hgj2`rGVkKzwwtf#xF7@4;R9+r4ybu zQa9_$95+^>`qKAnrpwD>{$84{pMuH_bRM=vbtmIXK-`!GkdN&SM7d89WpPDmYYzH` zCrN^3Hl^(D`8rb0yW}aiedUpbnN0?;?|z%NL}_{x4WrE2zwr^gt)(nKxZZH!Kw z4p}7ZJ{-XY$zimO;N~3pZ+Ywnn3AxUUU#umrL$s7(!;AfYi!SzT16!IHWyrux@z2D z9?O^lWPqg^TaN$PQ-;rNPsO^IvFu8TU|GY@nyv(0NEtnRyz0yKj;;9e?6~br$S^U= zruWkPr*Rae1shWJoJ_C6r?l2X7i~+H%JZzcy8An&ByLh1n&1|p5LyFd`UNdpC+U+QY4jSVYa1)FSX3->R;X(M%i^=fBT>&)o(#@URai4N4IV*!Y=lnhyK&t^?4QsRUTv}p z{`~EA@xIOw<+|@ILk3T5?`N~V$+P>*16w0oVHu3f%{{>H;p26fnp-e^EzMnmo5gZd zO>Rx3jdHeALvZ2YP3P#d_R)>7NxIFnx=$6$ludoDko)sSB-o;FpGHW1QT}P0(OiiP zwhttTIP$}l#O#gXmB(o(7*nj}=5h9BZc(W>x`Em+*I-0>s;HZGen@l+d$Q|(se21` z7{%V(8>aT&t#BRhQ**F=eCSWR>HX4u%>l<5zb$W66T2MBUcc?G<9it0BKG?OaW|0m z@n7EY_u2_xzeVHt`Siz)RoD~TWUU;C{Ls~`_DLjB{72}=|bT^L`P_shhxpx73t+rVoFAw!3o-4iqVC z&mWt_$9cOqKjnl^*3`xcp^W^S<$FHT)>5<)HmK9)c(AXp16u_rF?aTHIz5Y`aobO< zY58^UC|MDC;JgB9L0|cbSV1p*Q5YB6UKZ8S4A^i#{@H2uuiC zUAyNxiGJ!zS)cg{VQ=QYAk7&A-W(#sM)oGHjH6FZi4fK?SZ#~77D zw+7Q7KEECB$0a4#^byD3!*-v}eM|n{Li78pJ7ALTYsZH1~S7`p=@ zJ|Kp1PoHK+uO9y9N4Uc#-&{lUhX~e0TZM0qDIR)BY-IwD{64UojWV;FV67aE{(a$h zS!TABWX}ZQRA@BbjK#e0ZgqX7q>}PagFdaQ?_eF&NaI{^v^bw8`Q{c%{T#!2I&m{= zX(`>u*;MmxfIj55OMEgHUg!A&9>P@9NFzKUYN@o`D8PA#m&9*%O915WNt#UgOcN=^ zm7j-q{@a*iLbC9&l^6h5?tECxW#-q?Rc?}ipIyI8T`Dr|vP5mo1%1*?q=qm*!NUN( z+@DJV{A?jlI(T>Ptrd;<#*Fo;3`L^48;VC7Lx)wFiF0=_V=qY*uBz|lX#2Vb4{2xF z29i)tN|ygZz0Uri?cU@0TtXh_if1hT)!W8XFgffc!Mjv>+<7MVJT+#g1pZ!q4)QZ? zs#%TNEuHIkZu1tWgK0D83P~ly<65xJbj>_2k#DZm+R_VAobkSM9r&K25S*RvasKd` zw^~pfr%8Rz?qizB%pT@3k!<3Tzkl~-ZSh7J`0NQ3Vc-hY#Xy^kg6(+jXS8jwrWw9$ z_2a!0L-NaeIVR|^Gc)mi9&c~d9rfuZff`)DxnA-As9|~0?5^H$S^uOo!?s_$vy=(q zq?b~Be=)?rN6{b0DN$Xxog#yj(t_-yp@4e4SJ)16$;%r>kV}o~+@1c=uDB8vpH7QT z3+T-bVLX-?7lN5a{Uifz+?dkOvX~x{tle-j%QZ!vl+)4-Dz{C_K^!TzGWFJ)s@!5b zFD^FAWY~4*Xj#nHuu`aIf<6$XYz_LYlMqjHgOpAlnxn}e(>_3;JEVPLIZHu-5%F8qggEDz9gzbi0`hz*_s>6qaOk0Eta_13YcbjNq9Fr}-b3G0{nxxzK19)uvQ&sfeAuGf{>;evuDHV2@`Z|=nliC|JqTRJ*7ydDRxv*L zt{--78zVKMP)LZ}fT#1COslAH2G~t|f09YF*Ds|(i29#0YuLS9(tK!6mN3t$zlr3! zt~Y!QX9Y}8TdN8MZ~DM$zOlBpVUiliKP)RPH8Qk+!SoPC(g+i!8u}<1ttU&40Q+5> z54#?x2ALrJqAj(iHn0L`OiF73SLF`Hsb#d>%6r59Zg!^66E{oXVk}+26!zY5rkDs{ z{);kXq8l(!>xDo$YBAwx6Y(W@OJilI1vI|xakp9Gp4z<0xNp7_nE{gLe~Rjc=RP&v zf(gO2fUac&4PfbLY_{2aRCBO;HkHyFVj`5V<43C&$^pO1AiYU?c<9xW_^9zvN+R%2 z`z6=C+b%qN{aBw(*yUC0N0*NLEkt1`D;zX1v`-nzEtT)AJGaXWb~If9kE!jY#`o=- z^DH)aV&1XqvFh@&g#X~4cYE*Ta*GD0jjdicTm^i1tVL$U~PFFy}-{<^S-4M8pp3?UiD{<{H zt-h-tPZKDnrxri9#VTJRA^t4-ylaoJwd-bl7<5$2qKq^Lg}>*Zn}YG+ygygN*K5D9 zNb%v%ov!%}0nqc!d#Ucd!RC42$`1$Mo1_ep5LEi7(C6^hy1Kn%#JQZR0fE2g`feYO z?gO+3)O`iaK@^uZw!zNU)uodq*Xhs$;CJ!y`bGH#Bdq?cGM^%?gl7>CsCU~r-Ie@3 zP^)tOHNcAwFjy|gY#3(Ziyk)>R>9o~j zz*97xiT@mycPqb}9%tC~-5;^tFl&N1P8i8@ISg{6bNdVcqYu%GDqgtY>;r(7+4 zyr%yZr-fzaprq`4 z?v`ETkzBIzA@{mRGn{xhFJL$OllE-)DCz{}3inARhH>R$ZXVYNGF)qf^rko%O|*z)_qGh;S|KTuFC3#@cZm(K-?ZKPW6ifetI@_}fh ztomYh&%up=s<>94!sX6O1kx!5LM!!VBupISl*(TLG9*BJdckfbo?|t;g4YMNtN1R| z(d5>9)Z-|VL+a#o6EAJp_s2G(!GFuhaRWD{lgMVx;_1udx}H!G{#jBtK%G89?bj~w z`mD%ag{U~Twe>&Xv`|_{qGqs`VP{qF7B%jc6p_Bc4fnrxQ5#66&idCB0b4Uw3wVI+ zVImd5<#SMRW;I3ZsSP)ud}rsh~@zj}3s6zIFY z-yd+(qX#^#&!^C3 z&Ge-N0%B|HY4)9YYE7aO{Tgk_jZMhMS683*w$7hl4EC1@xa|H1l>h$dd9k%QKFqgR z{rF9lYm4LRe>Km3SqYo%6EJxPGb4vmb=Dh5CG*@F7T?-pRa8Wd-EO8!?x}#D+5m}l#$%RZ|o&{m9 zlpRZ2ok*l>|HD1irR@7u9sWIXR^BBXfN}3ne!}$6RFj2w7;WI97?L2M_|2YK?zf~^ zeE+7PSs0Lcal+2t+ejhiZWpi0WyzTYxj(kGiiMd>Ys`;UFo*a{0E7u(V~(<$P~!eJ zMOhFDR~Tz0;M>f&oPFo8yU9}gjwNs$EP8-8Am(fC;q-a&J2xvRk=5e-hS$#4e|iK| z*3Fij*f^$|NuT#;R?Fz#dvA4VmuCpD({zSZn~^2ywKJA}-wUX3P)0S%;bOahde93( z?!o@8nFjrd%f;HrRzi*NO&lb<$KJr8(aV|n#D&d9?DA7q*ukr!o>u@x!A1GqFca(s z%+Axjkr6K#=p3Zt(q%AtC97lh#^YX1-{v(>+*!2FLo&7F!~S%Wb^UsB^lSacv&fq6GS!p3iLl5*WaL3yh~MU z;*Iy&jKmpugdW$33d|_}qV{+HdLiFq{s?Kh+V}t)ODqJt+Jme%t|QL@W&B|iAK^Mt;yGO|VSshv|Y^k=z#PZ}nz(0=BdEU#2C2?H?gJFOdj>hz!Gd;bm zB8k!YF1U;B3JIKK*N=u7>@g(OmL8LH@42c%z zM0_P;-x)^HWD{ap54?b@D2aU<5%}REx~T^^Eurn-{U`p(smI)iEiYLOX!UVn@I2eT z#k?riajUl_0OD&)bSK$W=g1qZ*e?5u{<}&Ie9oT*_??^jpY|JZ{?Pi-3Wc8$8Z=x$ zjp{Z=kseYUf*l8i|0OuvQS$;q4+vSMe;e_*k(_*YadP~ZFkinS#T7Ub5&Q9C(DH3< zQ)PbeVmW{)sHwfL+)VhE)L?xe!4tpfcRiAhbUiy1vymiNZj@eC3&2Abnx-y2e1e~S zekWK5a@oYvUoy2OZK9p#5)4y7em(WFVWWL?`7aqwx;Y{F_!cn&>~u1aj60f`7!YA@ z{FVh)Fed5TT*sKJt-mdX%o1UZZ;>M8jIdVYuDg6uXumIkf(Kcqju{3+&>Ps%#9n&+;Nw|-SCdOA*;HLroYpi+ao(f))pJuq`cCPY|m{1H|$x*(%tr2@UhaR9B6va6kvM zQ|c){$_B@-s?2S#H8mF$b&*2n?*GU0xH!s;Ca6R=yPt?@A&8%E7fq7hVm&xK5rQHE zJn*aZYG0GapZS$w~<0nFwr^{4IF2K=WcPZ79XzxE|sLY&+kDI8X?~QBFVocu}`*5 zHJthgU;s@8H3AcFsg#?reC&e(zmpF6QwTOi8gY<592s!cE9;|3Vpjw+v>Dg;6JTt0xg*}H1c;VIcF;HS*rbXF9bExLBW2V`QbU(AYkTQ&fE~og&h$MWPcu;TOVii@ z7W2defeV%vmw))F{ftwNzU$S{dOI>bkJ8rAqF;vP`!0jwpX+ZL|5NgLp?q!?q!WU<7CyYFKYM+SAhaiw#6JJ z@-Khxq>cRfPaGuOJU)oe#G`@T#Tve-6Cj1FApO&jQnAG)FH4-o_k}TVHQ$vrzt@j7 zNeUc?MRwN8m6)yJ`9*WUL+r#?B5&1q#Q}KG2iSY>YHF&l<_*tG5}3ePZ%5OiJFQ1p z%nhuN99+unY3x?qcatFs7)3(BM^k+G2q4b`q>gxfvE%+~ z{dtW@9&xP#u;M%UU8+4tYR%$xU4z_T4d^IP=<-=wTa z_~ZU>P#P*F<}a80RsaoEwF*$D1;qhwR`oY_JnsLTrTBJB6722fVs# zOtj3EDb`;Z?HKV|nYWQ<3h4^p&w;Vsn;5XaMzsWM*qt9^`APG_a(s?zlQ-O^E zoDK=2QGQ3;C+U?Lsf{wO*+d4>%2BK<@tBJZEcHZYl0j2CXRonb0UpPH<4!{g2X+?=-*nXoe!V?C(O<_2fDukI|%` zYkgNRUt+%kMTLU1%|ygaVe;6o=DiVw;7{L$yb-AX{}%}-6mt+kb=qRz5$-O^3Q6-_ z2rn8ofU@=%vUd)DSbYn0@9T^ZzJo}$Arl5?Km5JNudYHHz~=*X%RSWLCH;IEbS7>Z z_nZxIl0!W9M8vc{M*Gp@{u`8Q=;eouQfA-Pwhut5js*u`)N8;|9a$A+__c7A=TQHiw(;OPOu^`sj9K1SVaX zv9VU;4`rPlWF#rN52*q?+z-fQC2Uy89#HS)8(^wBS-=;XUtIhTy*F&c<8I<7JU@yd znFt9d2Xw<&J4PU}^CchP6tW(GT`od`*kA_;F{pba+Fa-X;H?yZ1n;M;^22_0hwO>~ zfZuS?c>oStI0SHdbxZdFfE#(x{_oKW4|V-xydQe1I}$*M4WixwV)8p2xGdbEb~>$F zsALmq1Jph8%Kh{3K>v6*GJjGU4jJ%<0+{dT_^X!^k}don7H~RzQelBUCmD-0b4lUDgarRwA@0))dy>aAeUkynUP&h^|LEsK zZ;hM$@&mvuWJSa$tKSlHBC7Jw2`V-^>I#r%H13A}H>rhuOXnL(V1-Fw?U;RDj{R~C zfJ;C3eHb7{giC{1k_E@y$DUZhdZ&;q*+Cyj$Zc1`o6)0$*xiY*kpQQCA9YRtLrAQl zn+9h?vYj-ktB@;FANVq5SfGiKXaaApJDuV9pI&{)NP~frMBztPh>hOK+$8 zH^n=y$9>nKqq-dCUe2O+C|t~0%oM}4)!DC=W((1`VE0o9ca6w=Kp0i~sojnx%`#ZvnGDkR>`f;4%>+^qm;htnBHK)VKGP!$;%*;3X=cbQuPX!7t@Hn1(_ zJ^(%J-L453rv8dg#)bbD7DDjht1@K&2P$X+v>}?hfL6dJ7##Hz?Zr*IL-+xNv=DW% zo%D_xSx9zVVZaXPow$rwa7Kk3Xnz)u-@eO)^f(48Je>=>{XYbU04fK#@sVogiLx)i zfQvX0%D>w)syc!^16d3yvYUl*q=kQ;nrj^V_gT91*WY&FET1UNxd684^>96d#1y2< zQoF3C^l=1e-)z_+v~+Z~!2B{9DoAix>mJcB!9B6KTM|{>Hf1CLUvhn>sF>}`W(0^b zy+}qyg$Fq)1EPxdD}ub?etG{-HBT2YX#>c zsLu9VI0NtFzB@0P({5&b2*sGJqq4>F+0wH#yJ1yz!k#W2+q`!T`BK-Z;F2 zj|&(Gaz#_z%>qR%xxp16V?{^X=tB1O!_5td!(sr_#E-_?=+b$q_Eb$~+yoh*hK$`v z<0nEvj)MYlBF5@0KAALWR$cfFtU<;{ERlQ-L+l zWBC3SC5R&mbm&69-WCt=c-42h46F}3B@ah`H6u?3Pyw{=z?k)@Xq!w(`n^V^Jqv`^ zVD~Z(nM-r9idjmB3>@}cv!OK}Mi=4P!cN#JkoAg%80g5y#YBY`8%4b0PO}0EjqsI0 z^Ge5)=_`-!y$AD4$WLofe<7OSfyd`@U_rdDt)%!1KycG+3n@^^3p}|8Gbls`va@tT zS6m~zO=%IAaXlYi(F{_e8Ko8|fDS8`yodvLfIzxP52(QOH1~9L*wZfEf+k zQOhA^E}(VPEn`wHxn!Y9_H%LRSQLP|!WtQ3qOQZz`X?N0h(rb!XsP9(0_8l|?RtkDMe{NU0&prU-iCt8G*hy7@P$!DqDP57AMJEBO#?Qdbfm`J{_{#qKr92h_4j}ez&1yrj z1u9g-5CEc!=Ootm_woJGsXpJ)Oza>Rwm>1W`k1ERb?#l5XTKSs6q{H4Zw}mU88*_q zyRe)7qskL0#N}Z%TNpN~dJdmT6&ve`W^A4me9dYAwsd8$ApSz=h6)>Cq^o&CjM1~H zFCoqR5~lziBfsZlMRD;HNpgww{#$Zv)Hv8~!y)q;8vK2tq0FwwPso}2S77T4D!>?F zh8BZ|;)N&7q@+B~ryq-)O-Rb36qWoSt4hcW!i|Y)?>L*>oQV&FMq3zgTOH`Y^W}QJ z{fWq7Rz&<__~t7N5DIty!AYd13&AA$$0vQnd@5#v9{|~YuJHH_!OtK4V^~p2>D384 zowc3Vh*`~%0Mkp#owQqIB;uTrAfHIp3hUI=H>gO=A`OR>Ic7Wmxs;4R(hVc>t%opW z0H-7igI~#|cSc<+ye@-5`F5cMrnKZzqm^u|K ztSrk1=m}9v#48ebg{o%TJ9CQJnU)ejH&00fzl2Aj>} z=;+M45!`I~0a$d}lLr4$MZ6LIi7y4o8}iA&#Dx1WhXTaX@d*ixt>CunC;*sJO2TOi zCqmHEJbkEsY+m*QePP*N@VnpOF_&L-!{Jx)EDf$s0)+HeslGfVHexdno6Nk(YL?I( zWT-<8nxEEd2%Bafu2$V^H27QRiPSufKH#+YH&p1Lm_Zj6si%Iz-e2GS3zW4CoR~Q@ zr$8CCN|q5$Ip(&=eLc`XPiQiVQXyaW@*De0$VRAjYLQm!Pf_Vr@R%+veq3-ihd@fT zgHdCEabOwy-+%iCq4I zK?(qY@_6{`=e>Gk5+Z;*c=nA&$Dr_%mDTmF`8xvm?`orN(b^=m&{sPs6+IPPEaVaY zPAoeEW#yQO@^1@3H6k-|*2$D5Nsmjf733mwqD@EYbJ^7y6#3z@4H-EGl-SoP1;WFI z=*Z9jz+#wLlaUhKeNae9D6{Q65CZ!)d{@A6W6`QWST%-g8sp$x0YYm$4e>}T5UxWu z?d1%G^`bfX*wc_L<)zQNfmMqiE4CSt_%b_RTY1Gr$`gMlhL1QMDh zxLj%7Ps^`j7KNLg*ZF>^xa7Ly*-n_|#;m*joD2388d5&1TX{*f5`KW@>ZYK>oFBNh z^jRQ2v07B6%O9Zti|2@Hbsxu7bGAfMHykVA)X`&hqsB9hc^BQsg?-y5b)w|`hjs_c zG5Wbi>p9B!BdM#VpnCNf^(hsN=cf_{Dcl=oZ_QylE)`yaZ$g@6QruskJVeIyGzdSX zezCe{hOcik2@l~BOTW(5=sqU^+;L|nImI4!Du}7;+$G<@;3+o)qbL9FF+j30pe*u36 zh3KI7#{6z5GfM0vC0Pl*e!o1C8eYvS2S>a9fhnR>K(3^i8vx~1;Ux}BDM;~&i6~pK zZ}GD#Tztuz*%xmCL3svC%1Ujq`-tLr=7uN4afV1zG$4+41l>>dk_hziKj!#iQJ~qP z_gC_%C>>mAdSGm*6%4loNJ;7FjFDu@SqI{8C%sUJb&3b$hC?co)*q>%{89uy=r>Y2 zHxpQ}Y&5%Pl7($2>d_1eIy$>t0dK$2V5g?GSXfv@9_#{m^ze)^T{BENZ5{CP_?Lj7 zF+TPSeFt3{LL`bs#qaa6&61W^{dWjuCrNqGBE zTlSxOT%T{i5Ys_?MYiIz}w&cEPA+AwDm~16&e>sibQAS7zr)hiqU@dt7#Q3 zZAo>S9l9bpgbo=Z=A9zZqt%R&*H@hi|4vUE0AvQTX&JB)q7r;GR`dS-qJ2n;2kJ32 zrYna9Ba)N>M*X&}{Nq{x>nNrMZltwmex zbdkWTw?h%iVH{uZPoUP|6ed%vh@`*r{V)Bwmd^|dC!`!cC2-H4>NVAhdZG#d*~!mh?v-Ih0Hflo z0vR8al5jW*J^XB_lC6DYoY)FOpyKtfKAuI^FY1$0vXPq3)3z?asD2St*p7h=V|3|6ol1tt z2KdcVukaC_&rO)@N#7K_vNlLur&@ywVw(^gom##`if3SvxO#OCMUetXlJ-jyI_h1L z;W;CsZIm4(xmHEO_&+(JnBWEZH@vDt7^oQBqP(h3=!!T%5;&%<0QbN-7f*_Fjvjt9 zyyV}v5oy%e>tpg2Y)aGo#>4u`_}%E3D=;o3M^D|Ag6!Wq=TUAc9@M@Ndp@l1r+1-jfe;m*uR8_oU=&w|6 zAHgxxbwuitg|Eo~Nfib|ml$;MXYy;BRtAaACYPdM?uATBuxaMdj-#=^FZqWBE_Jjqq%y>rqR>EfH67Xu4n(nw`T-q6R~X1y6WGj7FeWK(Y%X{vk=BtpE<%l#)i&BZN+rFZ}_N` z$dIzrRS#AHAR1g&TB-@*3n)#Yz-3LxT4H^{XZciKOHXIR6aV>+a@X5!mH47FM=TFg z2sYi9uP?q>le-=ecPqpa4r3d{GRzh}n6-1Ujq{aWbGj-m{O*|WzF+30i9l8tLpK#- z#B|=~li6|Nh#F?B-2e4j_{p;~{7EG0B=E~MzVv$`y1D~o^#Snvx;Xu4^15hAMG;Qw z{zQDn|AbGX=wtDFjpseP?cd(#V)U0oK>p!2`nI$r#>8JGpLcp=Qpt#6EmC%tt~MTE zV~|+!B|6r-uKvM4{OaQ@9f1@jB-}GJ=_0kD=6L8Nd@R`cw0}ZgX-HKrax0HSl-?kd zI`C>cPYAw0bB2DF#~g}3UjLpnxZ-Czwr=4|DHD#jnk%ut0n0Z4z2I6*+}Dh=e1!sw zZ_tl1SVul{p=Xnp`awygn(ygzIT)6f40B)ul6 diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index db5427dc3..6eb093e93 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -6,6 +6,7 @@ import pytest from bec_lib import messages from bec_lib.messages import _StoredDataInfo +from bec_qthemes import apply_theme from pytestqt.exceptions import TimeoutError as QtBotTimeoutError from qtpy.QtWidgets import QApplication, QMessageBox @@ -25,6 +26,10 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True) def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument + qapp = QApplication.instance() + apply_theme("light") + qapp.processEvents() + yield # if the test failed, we don't want to check for open widgets as @@ -36,7 +41,6 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus bec_dispatcher.stop_cli_server() testable_qtimer_class.check_all_stopped(qtbot) - qapp = QApplication.instance() qapp.processEvents() if hasattr(qapp, "os_listener") and qapp.os_listener: qapp.removeEventFilter(qapp.os_listener) diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 3dca50a20..59a207021 100644 --- a/tests/unit_tests/test_dark_mode_button.py +++ b/tests/unit_tests/test_dark_mode_button.py @@ -2,9 +2,8 @@ import pytest from qtpy.QtCore import Qt -from qtpy.QtWidgets import QApplication -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton # pylint: disable=unused-import @@ -21,7 +20,7 @@ def dark_mode_button(qtbot, mocked_client): button = DarkModeButton(client=mocked_client) qtbot.addWidget(button) qtbot.waitExposed(button) - set_theme("light") + apply_theme("light") yield button @@ -64,23 +63,10 @@ def test_dark_mode_button_changes_theme(dark_mode_button): Test that the dark mode button changes the theme correctly. """ with mock.patch( - "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.set_theme" - ) as mocked_set_theme: + "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.apply_theme" + ) as mocked_apply_theme: dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("dark") + mocked_apply_theme.assert_called_with("dark") dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("light") - - -def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button): - """ - Test that the dark mode button changes the theme correctly when the OS theme changes. - """ - qapp = QApplication.instance() - assert dark_mode_button.dark_mode_enabled is False - assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode" - qapp.theme_signal.theme_updated.emit("dark") - qtbot.wait(100) - assert dark_mode_button.dark_mode_enabled is True - assert dark_mode_button.mode_button.toolTip() == "Set Light Mode" + mocked_apply_theme.assert_called_with("light") diff --git a/tests/unit_tests/test_stop_button.py b/tests/unit_tests/test_stop_button.py index b5ecdc1f9..e428a7dec 100644 --- a/tests/unit_tests/test_stop_button.py +++ b/tests/unit_tests/test_stop_button.py @@ -17,10 +17,6 @@ def stop_button(qtbot, mocked_client): def test_stop_button(stop_button): assert stop_button.button.text() == "Stop" - assert ( - stop_button.button.styleSheet() - == "background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;" - ) stop_button.button.click() assert stop_button.queue.request_scan_halt.called stop_button.close() From 5bfb50fdc6dc45935608963fef4d9243aee7fb0c Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 08:53:11 +0200 Subject: [PATCH 002/155] ci: add artifact upload --- .github/workflows/pytest.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f6f5a84d6..64f9c0c71 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -57,6 +57,14 @@ jobs: id: coverage run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/ + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: image-references + path: bec_widgets/tests/reference_failures/ + if-no-files-found: ignore + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: From af320d812b9dda3eccac1df377ed48aae352c23e Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 15 Oct 2025 16:21:40 +0200 Subject: [PATCH 003/155] ci: install ttyd --- .github/actions/bw_install/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/bw_install/action.yml b/.github/actions/bw_install/action.yml index 548a278c3..5b7c08017 100644 --- a/.github/actions/bw_install/action.yml +++ b/.github/actions/bw_install/action.yml @@ -53,6 +53,7 @@ runs: sudo apt-get update sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1 + sudo apt-get -y install ttyd - name: Install Python dependencies shell: bash From 5c33f1a6d490257efd0006eeaed67021e33d2feb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 16:50:14 +0200 Subject: [PATCH 004/155] fix(bec_connector): widget_removed and name_established signals added --- bec_widgets/utils/bec_connector.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 48a29fd1c..b670d03a7 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -77,6 +77,8 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} + widget_removed = Signal() + name_established = Signal(str) def __init__( self, @@ -204,6 +206,10 @@ def _update_object_name(self) -> None: self._enforce_unique_sibling_name() # 2) Register the object for RPC self.rpc_register.add_rpc(self) + try: + self.name_established.emit(self.object_name) + except RuntimeError: + return def _enforce_unique_sibling_name(self): """ @@ -450,6 +456,7 @@ def remove(self): # i.e. Curve Item from Waveform else: self.rpc_register.remove_rpc(self) + self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS) def get_config(self, dict_output: bool = True) -> dict | BaseModel: """ From a767ee83317a5c3ddfc7e33dd7a0df2af47b4fe3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:24:40 +0200 Subject: [PATCH 005/155] feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively --- bec_widgets/utils/widget_io.py | 83 +++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 22a754d9a..443eb814e 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -2,8 +2,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Type, TypeVar, cast import shiboken6 as shb +from bec_lib import bec_logger from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -21,6 +23,13 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils import BECConnector + +logger = bec_logger.logger + +TAncestor = TypeVar("TAncestor", bound=QWidget) + class WidgetHandler(ABC): """Abstract base class for all widget handlers.""" @@ -465,13 +474,19 @@ def _get_becwidget_ancestor(widget): """ from bec_widgets.utils import BECConnector + # Guard against deleted/invalid Qt wrappers if not shb.isValid(widget): return None - parent = widget.parent() + + # Retrieve first parent + parent = widget.parent() if hasattr(widget, "parent") else None + # Walk up, validating each step while parent is not None: + if not shb.isValid(parent): + return None if isinstance(parent, BECConnector): return parent - parent = parent.parent() + parent = parent.parent() if hasattr(parent, "parent") else None return None @staticmethod @@ -553,6 +568,70 @@ def import_config_from_dict(widget, config: dict, set_values: bool = False) -> N WidgetIO.set_value(child, value) WidgetHierarchy.import_config_from_dict(child, widget_config, set_values) + @staticmethod + def get_bec_connectors_from_parent(widget) -> list: + """ + Return all BECConnector instances whose closest BECConnector ancestor is the given widget, + including the widget itself if it is a BECConnector. + """ + from bec_widgets.utils import BECConnector + + connectors: list[BECConnector] = [] + if isinstance(widget, BECConnector): + connectors.append(widget) + for child in widget.findChildren(BECConnector): + if WidgetHierarchy._get_becwidget_ancestor(child) is widget: + connectors.append(child) + return connectors + + @staticmethod + def find_ancestor( + widget: QWidget | BECConnector, ancestor_class: Type[TAncestor] | str + ) -> TAncestor | None: + """ + Find the closest ancestor of the specified class (or class-name string). + + Args: + widget(QWidget): The starting widget. + ancestor_class(Type[TAncestor] | str): The ancestor class or class-name string to search for. + + Returns: + TAncestor | None: The closest ancestor of the specified class, or None if not found. + """ + if widget is None or not shb.isValid(widget): + return None + + try: + from bec_widgets.utils import BECConnector # local import to avoid cycles + + is_bec_target = False + if isinstance(ancestor_class, str): + is_bec_target = ancestor_class == "BECConnector" + elif isinstance(ancestor_class, type): + is_bec_target = issubclass(ancestor_class, BECConnector) + + if is_bec_target: + ancestor = WidgetHierarchy._get_becwidget_ancestor(widget) + return cast(TAncestor, ancestor) + except Exception as e: + logger.error(f"Error importing BECConnector: {e}") + + parent = widget.parent() if hasattr(widget, "parent") else None + while parent is not None: + if not shb.isValid(parent): + return None + try: + if isinstance(ancestor_class, str): + if parent.__class__.__name__ == ancestor_class: + return cast(TAncestor, parent) + else: + if isinstance(parent, ancestor_class): + return cast(TAncestor, parent) + except Exception as e: + logger.error(f"Error checking ancestor class: {e}") + parent = parent.parent() if hasattr(parent, "parent") else None + return None + # Example usage def hierarchy_example(): # pragma: no cover From 76639b3e0405e0bc6c667472db3609df2d639aea Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:05:43 +0200 Subject: [PATCH 006/155] feat(bec_widget): attach/detach method for all widgets + client regenerated --- bec_widgets/utils/bec_widget.py | 24 ++++++++++++++++++- .../positioner_box/positioner_box.py | 2 +- .../positioner_box_2d/positioner_box_2d.py | 2 ++ .../positioner_group/positioner_group.py | 2 +- .../control/scan_control/scan_control.py | 2 +- .../widgets/editors/monaco/monaco_widget.py | 3 +++ .../widgets/editors/website/website.py | 11 ++++++++- .../ring_progress_bar/ring_progress_bar.py | 3 +++ .../services/bec_status_box/bec_status_box.py | 2 +- 9 files changed, 45 insertions(+), 6 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ed58aeb2a..dccd82ff5 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import darkdetect +import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QObject @@ -14,6 +15,7 @@ from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout +from bec_widgets.utils.widget_io import WidgetHierarchy if TYPE_CHECKING: # pragma: no cover from bec_widgets.widgets.containers.dock import BECDock @@ -27,7 +29,7 @@ class BECWidget(BECConnector): # The icon name is the name of the icon in the icon theme, typically a name taken # from fonts.google.com/icons. Override this in subclasses to set the icon name. ICON_NAME = "widgets" - USER_ACCESS = ["remove"] + USER_ACCESS = ["remove", "attach", "detach"] # pylint: disable=too-many-arguments def __init__( @@ -124,6 +126,26 @@ def screenshot(self, file_name: str | None = None): screenshot.save(file_name) logger.info(f"Screenshot saved to {file_name}") + def attach(self): + dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) + if dock is None: + return + + if not dock.isFloating(): + return + dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock) + + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) + if dock is None: + return + if dock.isFloating(): + return + dock.setFloating() + def cleanup(self): """Cleanup the widget.""" with RPCRegister.delayed_broadcast(): diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 78cd5fa21..4a686d8cf 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase): PLUGIN = True RPC = True - USER_ACCESS = ["set_positioner", "screenshot"] + USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"] device_changed = Signal(str, str) # Signal emitted to inform listeners about a position update position_update = Signal(float) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 236303805..f0853e5b6 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -37,6 +37,8 @@ class PositionerBox2D(PositionerBoxBase): USER_ACCESS = [ "set_positioner_hor", "set_positioner_ver", + "attach", + "detach", "screenshot", "enable_controls_hor", "enable_controls_hor.setter", diff --git a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py index f3ac8892a..e16c83718 100644 --- a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +++ b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py @@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget): PLUGIN = True ICON_NAME = "grid_view" - USER_ACCESS = ["set_positioners"] + USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"] # Signal emitted to inform listeners about a position update of the first positioner position_update = Signal(float) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 043250e3c..27bad0234 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget): Widget to submit new scans to the queue. """ - USER_ACCESS = ["remove", "screenshot"] + USER_ACCESS = ["attach", "detach", "screenshot"] PLUGIN = True ICON_NAME = "tune" ARG_BOX_POSITION: int = 2 diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 076005309..eb05cec70 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -32,6 +32,9 @@ class MonacoWidget(BECWidget, QWidget): "set_vim_mode_enabled", "set_lsp_header", "get_lsp_header", + "attach", + "detach", + "screenshot", ] def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): diff --git a/bec_widgets/widgets/editors/website/website.py b/bec_widgets/widgets/editors/website/website.py index 7839b7891..fa9c8815d 100644 --- a/bec_widgets/widgets/editors/website/website.py +++ b/bec_widgets/widgets/editors/website/website.py @@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget): PLUGIN = True ICON_NAME = "travel_explore" - USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"] + USER_ACCESS = [ + "set_url", + "get_url", + "reload", + "back", + "forward", + "attach", + "detach", + "screenshot", + ] def __init__( self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index eeb413070..6385e3875 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -92,6 +92,9 @@ class RingProgressBar(BECWidget, QWidget): "set_diameter", "reset_diameter", "enable_auto_updates", + "attach", + "detach", + "screenshot", ] def __init__( diff --git a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py index cd21e9b6b..ca22a2f68 100644 --- a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py +++ b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py @@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget): PLUGIN = True CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"] - USER_ACCESS = ["get_server_state", "remove"] + USER_ACCESS = ["get_server_state", "remove", "attach", "detach", "screenshot"] service_update = Signal(BECServiceInfoContainer) bec_core_state = Signal(str) From 64fecd16dd41869a83e4fd3be48f9ab4f92d0b21 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:25:01 +0200 Subject: [PATCH 007/155] fix(widget_state_manager): state manager can save all properties recursively to already existing settings --- bec_widgets/utils/widget_state_manager.py | 55 ++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 9537097c2..2efe56e78 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -15,6 +15,8 @@ QWidget, ) +from bec_widgets.utils.widget_io import WidgetHierarchy + logger = bec_logger.logger @@ -29,43 +31,58 @@ class WidgetStateManager: def __init__(self, widget): self.widget = widget - def save_state(self, filename: str = None): + def save_state(self, filename: str | None = None, settings: QSettings | None = None): """ Save the state of the widget to an INI file. Args: filename(str): The filename to save the state to. + settings(QSettings): Optional QSettings object to save the state to. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getSaveFileName( self.widget, "Save Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._save_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, save the state to the provided QSettings object + self._save_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def load_state(self, filename: str = None): + def load_state(self, filename: str | None = None, settings: QSettings | None = None): """ Load the state of the widget from an INI file. Args: filename(str): The filename to load the state from. + settings(QSettings): Optional QSettings object to load the state from. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getOpenFileName( self.widget, "Load Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._load_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, load the state from the provided QSettings object + self._load_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): + def _save_widget_state_qsettings( + self, widget: QWidget, settings: QSettings, recursive: bool = True + ): """ Save the state of the widget to QSettings. Args: widget(QWidget): The widget to save the state for. settings(QSettings): The QSettings object to save the state to. + recursive(bool): Whether to recursively save the state of child widgets. """ if widget.property("skip_settings") is True: return @@ -88,21 +105,32 @@ def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): settings.endGroup() # Recursively process children (only if they aren't skipped) - for child in widget.children(): + if not recursive: + return + + direct_children = widget.children() + bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget) + all_children = list( + set(direct_children) | set(bec_connector_children) + ) # to avoid duplicates + for child in all_children: if ( child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): - self._save_widget_state_qsettings(child, settings) + self._save_widget_state_qsettings(child, settings, False) - def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): + def _load_widget_state_qsettings( + self, widget: QWidget, settings: QSettings, recursive: bool = True + ): """ Load the state of the widget from QSettings. Args: widget(QWidget): The widget to load the state for. settings(QSettings): The QSettings object to load the state from. + recursive(bool): Whether to recursively load the state of child widgets. """ if widget.property("skip_settings") is True: return @@ -118,14 +146,21 @@ def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): widget.setProperty(name, value) settings.endGroup() + if not recursive: + return # Recursively process children (only if they aren't skipped) - for child in widget.children(): + direct_children = widget.children() + bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget) + all_children = list( + set(direct_children) | set(bec_connector_children) + ) # to avoid duplicates + for child in all_children: if ( child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): - self._load_widget_state_qsettings(child, settings) + self._load_widget_state_qsettings(child, settings, False) def _get_full_widget_name(self, widget: QWidget): """ From 3a10cac7c85e5ddf63889f2f53d91cd224040f92 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 13 Aug 2025 11:22:22 +0200 Subject: [PATCH 008/155] refactor(bec_main_window): main app theme renamed to View --- bec_widgets/widgets/containers/main_window/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index e4d386525..054e0bc91 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -303,7 +303,7 @@ def _setup_menu_bar(self): ######################################## # Theme menu - theme_menu = menu_bar.addMenu("Theme") + theme_menu = menu_bar.addMenu("View") theme_group = QActionGroup(self) light_theme_action = QAction("Light Theme", self, checkable=True) From 02db6307e4a6f303fa140bb8f869b0bf52adf17b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 7 Oct 2025 13:35:03 +0200 Subject: [PATCH 009/155] fix(web_console): added startup kwarg --- .../widgets/editors/web_console/web_console.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/editors/web_console/web_console.py b/bec_widgets/widgets/editors/web_console/web_console.py index e0ad7d4bd..62eede57e 100644 --- a/bec_widgets/widgets/editors/web_console/web_console.py +++ b/bec_widgets/widgets/editors/web_console/web_console.py @@ -172,9 +172,17 @@ class WebConsole(BECWidget, QWidget): PLUGIN = True ICON_NAME = "terminal" - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, + parent=None, + config=None, + client=None, + gui_id=None, + startup_cmd: str | None = "bec --nogui", + **kwargs, + ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self._startup_cmd = "bec --nogui" + self._startup_cmd = startup_cmd self._is_initialized = False _web_console_registry.register(self) self._token = _web_console_registry._token From 7dcaf8fe4c15a0eeaf9cbf5675b52a7254cb0b35 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 15:58:29 +0200 Subject: [PATCH 010/155] feat(advanced_dock_area): added ads based dock area with profiles --- bec_widgets/__init__.py | 16 + .../jupyter_console/jupyter_console_window.py | 16 +- .../containers/advanced_dock_area/__init__.py | 0 .../advanced_dock_area/advanced_dock_area.py | 931 +++++++++++++++ .../advanced_dock_area/profile_utils.py | 79 ++ .../toolbar_components/__init__.py | 0 .../toolbar_components/workspace_actions.py | 183 +++ tests/unit_tests/test_advanced_dock_area.py | 1051 +++++++++++++++++ 8 files changed, 2268 insertions(+), 8 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py create mode 100644 tests/unit_tests/test_advanced_dock_area.py diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 2621e27e0..3d7d19fbd 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,4 +1,20 @@ +import os +import sys + +import PySide6QtAds as QtAds + from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +if sys.platform.startswith("linux"): + qt_platform = os.environ.get("QT_QPA_PLATFORM", "") + if qt_platform != "offscreen": + os.environ["QT_QPA_PLATFORM"] = "xcb" + +# Default QtAds configuration +QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) +QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True +) + __all__ = ["BECWidget", "SafeSlot", "SafeProperty"] diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 6c80dd130..88a7dc44d 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -16,6 +16,7 @@ from bec_widgets.utils import BECDispatcher from bec_widgets.utils.widget_io import WidgetHierarchy as wh +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole @@ -44,6 +45,7 @@ def __init__(self, parent=None): "wh": wh, "dock": self.dock, "im": self.im, + "ads": self.ads, # "mi": self.mi, # "mm": self.mm, # "lm": self.lm, @@ -119,14 +121,12 @@ def _init_ui(self): tab_widget.addTab(sixth_tab, "Image Next Gen") tab_widget.setCurrentIndex(1) # - # seventh_tab = QWidget() - # seventh_tab_layout = QVBoxLayout(seventh_tab) - # self.scatter = ScatterWaveform() - # self.scatter_mi = self.scatter.main_curve - # self.scatter.plot("samx", "samy", "bpm4i") - # seventh_tab_layout.addWidget(self.scatter) - # tab_widget.addTab(seventh_tab, "Scatter Waveform") - # tab_widget.setCurrentIndex(6) + seventh_tab = QWidget() + seventh_tab_layout = QVBoxLayout(seventh_tab) + self.ads = AdvancedDockArea(gui_id="ads") + seventh_tab_layout.addWidget(self.ads) + tab_widget.addTab(seventh_tab, "ADS") + tab_widget.setCurrentIndex(2) # # eighth_tab = QWidget() # eighth_tab_layout = QVBoxLayout(eighth_tab) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py new file mode 100644 index 000000000..fda1613fa --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -0,0 +1,931 @@ +from __future__ import annotations + +import os +from typing import Literal, cast + +import PySide6QtAds as QtAds +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Signal +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from shiboken6 import isValid + +from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler +from bec_widgets.utils import BECDispatcher +from bec_widgets.utils.property_editor import PropertyEditor +from bec_widgets.utils.toolbars.actions import ( + ExpandableMenuAction, + MaterialIconAction, + WidgetAction, +) +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.utils.widget_state_manager import WidgetStateManager +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + SETTINGS_KEYS, + is_profile_readonly, + list_profiles, + open_settings, + profile_path, + read_manifest, + set_profile_readonly, + write_manifest, +) +from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import ( + WorkspaceConnection, + workspace_bundle, +) +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D +from bec_widgets.widgets.control.scan_control import ScanControl +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap +from bec_widgets.widgets.plots.image.image import Image +from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap +from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform +from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.utility.logpanel import LogPanel +from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + +class DockSettingsDialog(QDialog): + + def __init__(self, parent: QWidget, target: QWidget): + super().__init__(parent) + self.setWindowTitle("Dock Settings") + self.setModal(True) + layout = QVBoxLayout(self) + + # Property editor + self.prop_editor = PropertyEditor(target, self, show_only_bec=True) + layout.addWidget(self.prop_editor) + + +class SaveProfileDialog(QDialog): + """Dialog for saving workspace profiles with read-only option.""" + + def __init__(self, parent: QWidget, current_name: str = ""): + super().__init__(parent) + self.setWindowTitle("Save Workspace Profile") + self.setModal(True) + self.resize(400, 150) + layout = QVBoxLayout(self) + + # Name input + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Profile Name:")) + self.name_edit = QLineEdit(current_name) + self.name_edit.setPlaceholderText("Enter profile name...") + name_row.addWidget(self.name_edit) + layout.addLayout(name_row) + + # Read-only checkbox + self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)") + layout.addWidget(self.readonly_checkbox) + + # Info label + info_label = QLabel("Read-only profiles are protected from modification and deletion.") + info_label.setStyleSheet("color: gray; font-size: 10px;") + layout.addWidget(info_label) + + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch(1) + self.save_btn = QPushButton("Save") + self.save_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + self.save_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self.save_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Enable/disable save button based on name input + self.name_edit.textChanged.connect(self._update_save_button) + self._update_save_button() + + def _update_save_button(self): + """Enable save button only when name is not empty.""" + self.save_btn.setEnabled(bool(self.name_edit.text().strip())) + + def get_profile_name(self) -> str: + """Get the entered profile name.""" + return self.name_edit.text().strip() + + def is_readonly(self) -> bool: + """Check if the profile should be marked as read-only.""" + return self.readonly_checkbox.isChecked() + + +class AdvancedDockArea(BECWidget, QWidget): + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "widget_map", + "widget_list", + "lock_workspace", + "attach_all", + "delete_all", + "mode", + "mode.setter", + ] + + # Define a signal for mode changes + mode_changed = Signal(str) + + def __init__( + self, + parent=None, + mode: str = "developer", + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + *args, + **kwargs, + ): + super().__init__(parent=parent, *args, **kwargs) + + # Title (as a top-level QWidget it can have a window title) + self.setWindowTitle("Advanced Dock Area") + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + # Init Dock Manager + self.dock_manager = CDockManager(self) + + # Dock manager helper variables + self._locked = False # Lock state of the workspace + + # Initialize mode property first (before toolbar setup) + self._mode = "developer" + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + # Toolbar + self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self._setup_toolbar() + self._hook_toolbar() + + # Place toolbar and dock manager into layout + self._root_layout.addWidget(self.toolbar) + self._root_layout.addWidget(self.dock_manager, 1) + + # Populate and hook the workspace combo + self._refresh_workspace_list() + + # State manager + self.state_manager = WidgetStateManager(self) + + # Developer mode state + self._editable = None + # Initialize default editable state based on current lock + self._set_editable(True) # default to editable; will sync toolbar toggle below + + # Sync Developer toggle icon state after initial setup + dev_action = self.toolbar.components.get_action("developer_mode").action + dev_action.setChecked(self._editable) + + # Apply the requested mode after everything is set up + self.mode = mode + + def _make_dock( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool = True, + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, + start_floating: bool = False, + ) -> CDockWidget: + dock = CDockWidget(widget.objectName()) + dock.setWidget(widget) + dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetClosable, closable) + dock.setFeature(CDockWidget.DockWidgetFloatable, floatable) + dock.setFeature(CDockWidget.DockWidgetMovable, movable) + + self._install_dock_settings_action(dock, widget) + + def on_dock_close(): + widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + + def on_widget_destroyed(): + if not isValid(dock): + return + dock.closeDockWidget() + dock.deleteDockWidget() + + dock.closeRequested.connect(on_dock_close) + if hasattr(widget, "widget_removed"): + widget.widget_removed.connect(on_widget_destroyed) + + dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) + self.dock_manager.addDockWidget(area, dock) + if start_floating: + dock.setFloating() + return dock + + def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: + action = MaterialIconAction( + icon_name="settings", tooltip="Dock settings", filled=True, parent=self + ).action + action.setToolTip("Dock settings") + action.setObjectName("dockSettingsAction") + action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + dock.setTitleBarActions([action]) + dock.setting_action = action + + def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: + dlg = DockSettingsDialog(self, widget) + dlg.resize(600, 600) + dlg.exec() + + def _apply_dock_lock(self, locked: bool) -> None: + if locked: + self.dock_manager.lockDockWidgetFeaturesGlobally() + else: + self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures) + + def _delete_dock(self, dock: CDockWidget) -> None: + w = dock.widget() + if w and isValid(w): + w.close() + w.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Return ADS DockWidgetArea from a human-friendly direction string. + If *where* is None, fall back to instance default. + """ + d = (where or getattr(self, "_default_add_direction", "right") or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea) + + ################################################################################ + # Toolbar Setup + ################################################################################ + + def _setup_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + + PLOT_ACTIONS = { + "waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"), + "scatter_waveform": ( + ScatterWaveform.ICON_NAME, + "Add Scatter Waveform", + "ScatterWaveform", + ), + "multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"), + "image": (Image.ICON_NAME, "Add Image", "Image"), + "motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"), + "heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"), + } + DEVICE_ACTIONS = { + "scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"), + "positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"), + "positioner_box_2D": ( + PositionerBox2D.ICON_NAME, + "Add Device 2D Box", + "PositionerBox2D", + ), + } + UTIL_ACTIONS = { + "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), + "status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), + "progress_bar": ( + RingProgressBar.ICON_NAME, + "Add Circular ProgressBar", + "RingProgressBar", + ), + "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), + "bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"), + "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), + "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), + } + + # Create expandable menu actions (original behavior) + def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): + self.toolbar.components.add_safe( + key, + ExpandableMenuAction( + label=label, + actions={ + k: MaterialIconAction( + icon_name=v[0], tooltip=v[1], filled=True, parent=self + ) + for k, v in mapping.items() + }, + ), + ) + b = ToolbarBundle(key, self.toolbar.components) + b.add_action(key) + self.toolbar.add_bundle(b) + + _build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS) + _build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS) + _build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS) + + # Create flat toolbar bundles for each widget type + def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): + bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components) + + for action_id, (icon_name, tooltip, widget_type) in mapping.items(): + # Create individual action for each widget type + flat_action_id = f"flat_{action_id}" + self.toolbar.components.add_safe( + flat_action_id, + MaterialIconAction( + icon_name=icon_name, tooltip=tooltip, filled=True, parent=self + ), + ) + bundle.add_action(flat_action_id) + + self.toolbar.add_bundle(bundle) + + _build_flat_bundles("plots", PLOT_ACTIONS) + _build_flat_bundles("devices", DEVICE_ACTIONS) + _build_flat_bundles("utils", UTIL_ACTIONS) + + # Workspace + spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components) + spacer = QWidget(parent=self.toolbar.components.toolbar) + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) + spacer_bundle.add_action("spacer") + self.toolbar.add_bundle(spacer_bundle) + + self.toolbar.add_bundle(workspace_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self) + ) + + # Dock actions + self.toolbar.components.add_safe( + "attach_all", + MaterialIconAction( + icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self + ), + ) + self.toolbar.components.add_safe( + "screenshot", + MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self), + ) + self.toolbar.components.add_safe( + "dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self) + ) + # Developer mode toggle (moved from menu into toolbar) + self.toolbar.components.add_safe( + "developer_mode", + MaterialIconAction( + icon_name="code", tooltip="Developer Mode", checkable=True, parent=self + ), + ) + bda = ToolbarBundle("dock_actions", self.toolbar.components) + bda.add_action("attach_all") + bda.add_action("screenshot") + bda.add_action("dark_mode") + bda.add_action("developer_mode") + self.toolbar.add_bundle(bda) + + # Default bundle configuration (show menus by default) + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + + # Store mappings on self for use in _hook_toolbar + self._ACTION_MAPPINGS = { + "menu_plots": PLOT_ACTIONS, + "menu_devices": DEVICE_ACTIONS, + "menu_utils": UTIL_ACTIONS, + } + + def _hook_toolbar(self): + + def _connect_menu(menu_key: str): + menu = self.toolbar.components.get_action(menu_key) + mapping = self._ACTION_MAPPINGS[menu_key] + for key, (_, _, widget_type) in mapping.items(): + act = menu.actions[key].action + if widget_type == "LogPanel": + act.setEnabled(False) # keep disabled per issue #644 + elif key == "terminal": + act.triggered.connect( + lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) + ) + elif key == "bec_shell": + act.triggered.connect( + lambda _, t=widget_type: self.new( + widget=t, + closable=True, + startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", + ) + ) + else: + act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_menu("menu_plots") + _connect_menu("menu_devices") + _connect_menu("menu_utils") + + # Connect flat toolbar actions + def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]): + for action_id, (_, _, widget_type) in mapping.items(): + flat_action_id = f"flat_{action_id}" + flat_action = self.toolbar.components.get_action(flat_action_id).action + if widget_type == "LogPanel": + flat_action.setEnabled(False) # keep disabled per issue #644 + else: + flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"]) + _connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"]) + _connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"]) + + self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) + self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) + # Developer mode toggle + self.toolbar.components.get_action("developer_mode").action.toggled.connect( + self._on_developer_mode_toggled + ) + + def _set_editable(self, editable: bool) -> None: + self.lock_workspace = not editable + self._editable = editable + + # Sync the toolbar lock toggle with current mode + lock_action = self.toolbar.components.get_action("lock").action + lock_action.setChecked(not editable) + lock_action.setVisible(editable) + + attach_all_action = self.toolbar.components.get_action("attach_all").action + attach_all_action.setVisible(editable) + + # Show full creation menus only when editable; otherwise keep minimal set + if editable: + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + else: + self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + + # Keep Developer mode UI in sync + self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + + def _on_developer_mode_toggled(self, checked: bool) -> None: + """Handle developer mode checkbox toggle.""" + self._set_editable(checked) + + ################################################################################ + # Adding widgets + ################################################################################ + @SafeSlot(popup_error=True) + def new( + self, + widget: BECWidget | str, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, + **kwargs, + ) -> BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget: Widget instance or a string widget type (factory-created). + closable: Whether the dock is closable. + floatable: Whether the dock is floatable. + movable: Whether the dock is movable. + start_floating: Start the dock in a floating state. + where: Preferred area to add the dock: "left" | "right" | "top" | "bottom". + If None, uses the instance default passed at construction time. + **kwargs: The keyword arguments for the widget. + Returns: + The widget instance. + """ + target_area = self._area_from_where(where) + + # 1) Instantiate or look up the widget + if isinstance(widget, str): + widget = cast( + BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs) + ) + widget.name_established.connect( + lambda: self._create_dock_with_name( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + ) + ) + return widget + + # If a widget instance is passed, dock it immediately + self._create_dock_with_name( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + ) + return widget + + def _create_dock_with_name( + self, + widget: BECWidget, + closable: bool = True, + floatable: bool = False, + movable: bool = True, + start_floating: bool = False, + area: QtAds.DockWidgetArea | None = None, + ): + target_area = area or self._area_from_where(None) + self._make_dock( + widget, + closable=closable, + floatable=floatable, + movable=movable, + area=target_area, + start_floating=start_floating, + ) + self.dock_manager.setFocus() + + ################################################################################ + # Dock Management + ################################################################################ + + def dock_map(self) -> dict[str, CDockWidget]: + """ + Return the dock widgets map as dictionary with names as keys and dock widgets as values. + + Returns: + dict: A dictionary mapping widget names to their corresponding dock widgets. + """ + return self.dock_manager.dockWidgetsMap() + + def dock_list(self) -> list[CDockWidget]: + """ + Return the list of dock widgets. + + Returns: + list: A list of all dock widgets in the dock area. + """ + return self.dock_manager.dockWidgets() + + def widget_map(self) -> dict[str, QWidget]: + """ + Return a dictionary mapping widget names to their corresponding BECWidget instances. + + Returns: + dict: A dictionary mapping widget names to BECWidget instances. + """ + return {dock.objectName(): dock.widget() for dock in self.dock_list()} + + def widget_list(self) -> list[QWidget]: + """ + Return a list of all BECWidget instances in the dock area. + + Returns: + list: A list of all BECWidget instances in the dock area. + """ + return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] + + @SafeSlot() + def attach_all(self): + """ + Return all floating docks to the dock area, preserving tab groups within each floating container. + """ + for container in self.dock_manager.floatingWidgets(): + docks = container.dockWidgets() + if not docks: + continue + target = docks[0] + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) + for d in docks[1:]: + self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.RightDockWidgetArea, d, target + ) + + @SafeSlot() + def delete_all(self): + """Delete all docks and widgets.""" + for dock in list(self.dock_manager.dockWidgets()): + self._delete_dock(dock) + + ################################################################################ + # Workspace Management + ################################################################################ + @SafeProperty(bool) + def lock_workspace(self) -> bool: + """ + Get or set the lock state of the workspace. + + Returns: + bool: True if the workspace is locked, False otherwise. + """ + return self._locked + + @lock_workspace.setter + def lock_workspace(self, value: bool): + """ + Set the lock state of the workspace. Docks remain resizable, but are not movable or closable. + + Args: + value (bool): True to lock the workspace, False to unlock it. + """ + self._locked = value + self._apply_dock_lock(value) + self.toolbar.components.get_action("save_workspace").action.setVisible(not value) + self.toolbar.components.get_action("delete_workspace").action.setVisible(not value) + for dock in self.dock_list(): + dock.setting_action.setVisible(not value) + + @SafeSlot(str) + def save_profile(self, name: str | None = None): + """ + Save the current workspace profile. + + Args: + name (str | None): The name of the profile. If None, a dialog will prompt for a name. + """ + if not name: + # Use the new SaveProfileDialog instead of QInputDialog + dialog = SaveProfileDialog(self) + if dialog.exec() != QDialog.Accepted: + return + name = dialog.get_profile_name() + readonly = dialog.is_readonly() + + # Check if profile already exists and is read-only + if os.path.exists(profile_path(name)) and is_profile_readonly(name): + suggested_name = f"{name}_custom" + reply = QMessageBox.warning( + self, + "Read-only Profile", + f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n" + f"Would you like to save it with a different name?\n" + f"Suggested name: '{suggested_name}'", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + # Show dialog again with suggested name pre-filled + dialog = SaveProfileDialog(self, suggested_name) + if dialog.exec() != QDialog.Accepted: + return + name = dialog.get_profile_name() + readonly = dialog.is_readonly() + + # Check again if the new name is also read-only (recursive protection) + if os.path.exists(profile_path(name)) and is_profile_readonly(name): + return self.save_profile() + else: + return + else: + # If name is provided directly, assume not read-only unless already exists + readonly = False + if os.path.exists(profile_path(name)) and is_profile_readonly(name): + QMessageBox.warning( + self, + "Read-only Profile", + f"The profile '{name}' is marked as read-only and cannot be overwritten.", + QMessageBox.Ok, + ) + return + + # Display saving placeholder + workspace_combo = self.toolbar.components.get_action("workspace_combo").widget + workspace_combo.blockSignals(True) + workspace_combo.insertItem(0, f"{name}-saving") + workspace_combo.setCurrentIndex(0) + workspace_combo.blockSignals(False) + + # Save the profile + settings = open_settings(name) + settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) + settings.setValue( + SETTINGS_KEYS["state"], b"" + ) # No QMainWindow state; placeholder for backward compat + settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) + self.dock_manager.addPerspective(name) + self.dock_manager.savePerspectives(settings) + self.state_manager.save_state(settings=settings) + write_manifest(settings, self.dock_list()) + + # Set read-only status if specified + if readonly: + set_profile_readonly(name, readonly) + + settings.sync() + self._refresh_workspace_list() + workspace_combo.setCurrentText(name) + + def load_profile(self, name: str | None = None): + """ + Load a workspace profile. + + Args: + name (str | None): The name of the profile. If None, a dialog will prompt for a name. + """ + # FIXME this has to be tweaked + if not name: + name, ok = QInputDialog.getText( + self, "Load Workspace", "Enter the name of the workspace profile to load:" + ) + if not ok or not name: + return + settings = open_settings(name) + + for item in read_manifest(settings): + obj_name = item["object_name"] + widget_class = item["widget_class"] + if obj_name not in self.widget_map(): + w = widget_handler.create_widget(widget_type=widget_class, parent=self) + w.setObjectName(obj_name) + self._make_dock( + w, + closable=item["closable"], + floatable=item["floatable"], + movable=item["movable"], + area=QtAds.DockWidgetArea.RightDockWidgetArea, + ) + + geom = settings.value(SETTINGS_KEYS["geom"]) + if geom: + self.restoreGeometry(geom) + # No window state for QWidget-based host; keep for backwards compat read + # window_state = settings.value(SETTINGS_KEYS["state"]) # ignored + dock_state = settings.value(SETTINGS_KEYS["ads_state"]) + if dock_state: + self.dock_manager.restoreState(dock_state) + self.dock_manager.loadPerspectives(settings) + self.state_manager.load_state(settings=settings) + self._set_editable(self._editable) + + @SafeSlot() + def delete_profile(self): + """ + Delete the currently selected workspace profile file and refresh the combo list. + """ + combo = self.toolbar.components.get_action("workspace_combo").widget + name = combo.currentText() + if not name: + return + + # Check if profile is read-only + if is_profile_readonly(name): + QMessageBox.warning( + self, + "Read-only Profile", + f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n" + f"Read-only profiles are protected from modification and deletion.", + QMessageBox.Ok, + ) + return + + # Confirm deletion for regular profiles + reply = QMessageBox.question( + self, + "Delete Profile", + f"Are you sure you want to delete the profile '{name}'?\n\n" + f"This action cannot be undone.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + file_path = profile_path(name) + try: + os.remove(file_path) + except FileNotFoundError: + return + self._refresh_workspace_list() + + def _refresh_workspace_list(self): + """ + Populate the workspace combo box with all saved profile names (without .ini). + """ + combo = self.toolbar.components.get_action("workspace_combo").widget + if hasattr(combo, "refresh_profiles"): + combo.refresh_profiles() + else: + # Fallback for regular QComboBox + combo.blockSignals(True) + combo.clear() + combo.addItems(list_profiles()) + combo.blockSignals(False) + + ################################################################################ + # Mode Switching + ################################################################################ + + @SafeProperty(str) + def mode(self) -> str: + return self._mode + + @mode.setter + def mode(self, new_mode: str): + if new_mode not in ["plot", "device", "utils", "developer", "user"]: + raise ValueError(f"Invalid mode: {new_mode}") + self._mode = new_mode + self.mode_changed.emit(new_mode) + + # Update toolbar visibility based on mode + if new_mode == "user": + # User mode: show only essential tools + self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + elif new_mode == "developer": + # Developer mode: show all tools (use menu bundles) + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + elif new_mode in ["plot", "device", "utils"]: + # Specific modes: show flat toolbar for that category + bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils" + self.toolbar.show_bundles([bundle_name]) + # self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"]) + else: + # Fallback to user mode + self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + + def cleanup(self): + """ + Cleanup the dock area. + """ + self.delete_all() + self.dark_mode_button.close() + self.dark_mode_button.deleteLater() + self.toolbar.cleanup() + super().cleanup() + + +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + dispatcher = BECDispatcher(gui_id="ads") + window = BECMainWindowNoRPC() + ads = AdvancedDockArea(mode="developer", root_widget=True) + window.setCentralWidget(ads) + window.show() + window.resize(800, 600) + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py new file mode 100644 index 000000000..47fe1ddd7 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -0,0 +1,79 @@ +import os + +from PySide6QtAds import CDockWidget +from qtpy.QtCore import QSettings + +MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default") +_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user") + + +def profiles_dir() -> str: + path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR) + os.makedirs(path, exist_ok=True) + return path + + +def profile_path(name: str) -> str: + return os.path.join(profiles_dir(), f"{name}.ini") + + +SETTINGS_KEYS = { + "geom": "mainWindow/Geometry", + "state": "mainWindow/State", + "ads_state": "mainWindow/DockingState", + "manifest": "manifest/widgets", + "readonly": "profile/readonly", +} + + +def list_profiles() -> list[str]: + return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini")) + + +def is_profile_readonly(name: str) -> bool: + """Check if a profile is marked as read-only.""" + settings = open_settings(name) + return settings.value(SETTINGS_KEYS["readonly"], False, type=bool) + + +def set_profile_readonly(name: str, readonly: bool) -> None: + """Set the read-only status of a profile.""" + settings = open_settings(name) + settings.setValue(SETTINGS_KEYS["readonly"], readonly) + settings.sync() + + +def open_settings(name: str) -> QSettings: + return QSettings(profile_path(name), QSettings.IniFormat) + + +def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: + settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks)) + for i, dock in enumerate(docks): + settings.setArrayIndex(i) + w = dock.widget() + settings.setValue("object_name", w.objectName()) + settings.setValue("widget_class", w.__class__.__name__) + settings.setValue("closable", getattr(dock, "_default_closable", True)) + settings.setValue("floatable", getattr(dock, "_default_floatable", True)) + settings.setValue("movable", getattr(dock, "_default_movable", True)) + settings.endArray() + + +def read_manifest(settings: QSettings) -> list[dict]: + items: list[dict] = [] + count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) + for i in range(count): + settings.setArrayIndex(i) + items.append( + { + "object_name": settings.value("object_name"), + "widget_class": settings.value("widget_class"), + "closable": settings.value("closable", type=bool), + "floatable": settings.value("floatable", type=bool), + "movable": settings.value("movable", type=bool), + } + ) + settings.endArray() + return items diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py new file mode 100644 index 000000000..616dcc08c --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget + +from bec_widgets import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + is_profile_readonly, + list_profiles, +) + + +class ProfileComboBox(QComboBox): + """Custom combobox that displays icons for read-only profiles.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def refresh_profiles(self): + """Refresh the profile list with appropriate icons.""" + + current_text = self.currentText() + self.blockSignals(True) + self.clear() + + lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False) + + for profile in list_profiles(): + if is_profile_readonly(profile): + self.addItem(lock_icon, f"{profile}") + # Set tooltip for read-only profiles + self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole) + else: + self.addItem(profile) + + # Restore selection if possible + index = self.findText(current_text) + if index >= 0: + self.setCurrentIndex(index) + + self.blockSignals(False) + + +def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for AdvancedDockArea. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + # Lock icon action + components.add_safe( + "lock", + MaterialIconAction( + icon_name="lock_open_right", + tooltip="Lock Workspace", + checkable=True, + parent=components.toolbar, + ), + ) + + # Workspace combo + combo = ProfileComboBox(parent=components.toolbar) + components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) + + # Save the current workspace icon + components.add_safe( + "save_workspace", + MaterialIconAction( + icon_name="save", + tooltip="Save Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + # Delete workspace icon + components.add_safe( + "refresh_workspace", + MaterialIconAction( + icon_name="refresh", + tooltip="Refresh Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + # Delete workspace icon + components.add_safe( + "delete_workspace", + MaterialIconAction( + icon_name="delete", + tooltip="Delete Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + + bundle = ToolbarBundle("workspace", components) + bundle.add_action("lock") + bundle.add_action("workspace_combo") + bundle.add_action("save_workspace") + bundle.add_action("refresh_workspace") + bundle.add_action("delete_workspace") + return bundle + + +class WorkspaceConnection(BundleConnection): + """ + Connection class for workspace actions in AdvancedDockArea. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "workspace" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "lock_workspace"): + raise AttributeError("Target widget must implement 'lock_workspace'.") + self._connected = False + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + self.components.get_action("lock").action.toggled.connect(self._lock_workspace) + self.components.get_action("save_workspace").action.triggered.connect( + self.target_widget.save_profile + ) + self.components.get_action("workspace_combo").widget.currentTextChanged.connect( + self.target_widget.load_profile + ) + self.components.get_action("refresh_workspace").action.triggered.connect( + self._refresh_workspace + ) + self.components.get_action("delete_workspace").action.triggered.connect( + self.target_widget.delete_profile + ) + + def disconnect(self): + if not self._connected: + return + # Disconnect the action from the target widget's method + self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace) + self.components.get_action("save_workspace").action.triggered.disconnect( + self.target_widget.save_profile + ) + self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( + self.target_widget.load_profile + ) + self.components.get_action("refresh_workspace").action.triggered.disconnect( + self._refresh_workspace + ) + self.components.get_action("delete_workspace").action.triggered.disconnect( + self.target_widget.delete_profile + ) + self._connected = False + + @SafeSlot(bool) + def _lock_workspace(self, value: bool): + """ + Switches the workspace lock state and change the icon accordingly. + """ + setattr(self.target_widget, "lock_workspace", value) + self.components.get_action("lock").action.setChecked(value) + icon = material_icon( + "lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False + ) + self.components.get_action("lock").action.setIcon(icon) + + @SafeSlot() + def _refresh_workspace(self): + """ + Refreshes the current workspace. + """ + combo = self.components.get_action("workspace_combo").widget + current_workspace = combo.currentText() + self.target_widget.load_profile(current_workspace) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py new file mode 100644 index 000000000..d6563a33e --- /dev/null +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -0,0 +1,1051 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import os +import tempfile +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest +from qtpy.QtCore import QSettings +from qtpy.QtWidgets import QDialog, QMessageBox + +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( + AdvancedDockArea, + DockSettingsDialog, + SaveProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + is_profile_readonly, + list_profiles, + open_settings, + profile_path, + read_manifest, + set_profile_readonly, + write_manifest, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def advanced_dock_area(qtbot, mocked_client): + """Create an AdvancedDockArea instance for testing.""" + widget = AdvancedDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_profile_dir(): + """Create a temporary directory for profile testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch.dict(os.environ, {"BECWIDGETS_PROFILE_DIR": temp_dir}): + yield temp_dir + + +class TestAdvancedDockAreaInit: + """Test initialization and basic properties.""" + + def test_init(self, advanced_dock_area): + assert advanced_dock_area is not None + assert isinstance(advanced_dock_area, AdvancedDockArea) + assert advanced_dock_area.mode == "developer" + assert hasattr(advanced_dock_area, "dock_manager") + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "dark_mode_button") + assert hasattr(advanced_dock_area, "state_manager") + + def test_rpc_and_plugin_flags(self): + assert AdvancedDockArea.RPC is True + assert AdvancedDockArea.PLUGIN is False + + def test_user_access_list(self): + expected_methods = [ + "new", + "widget_map", + "widget_list", + "lock_workspace", + "attach_all", + "delete_all", + ] + for method in expected_methods: + assert method in AdvancedDockArea.USER_ACCESS + + +class TestDockManagement: + """Test dock creation, management, and manipulation.""" + + def test_new_widget_string(self, advanced_dock_area, qtbot): + """Test creating a new widget from string.""" + initial_count = len(advanced_dock_area.dock_list()) + + # Create a widget by string name + widget = advanced_dock_area.new("Waveform") + + # Wait for the dock to be created (since it's async) + qtbot.wait(200) + + # Check that dock was actually created + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + # Check widget was returned + assert widget is not None + assert hasattr(widget, "name_established") + + def test_new_widget_instance(self, advanced_dock_area, qtbot): + """Test creating dock with existing widget instance.""" + from bec_widgets.widgets.plots.waveform.waveform import Waveform + + initial_count = len(advanced_dock_area.dock_list()) + + # Create widget instance + widget_instance = Waveform(parent=advanced_dock_area, client=advanced_dock_area.client) + widget_instance.setObjectName("test_widget") + + # Add it to dock area + result = advanced_dock_area.new(widget_instance) + + # Should return the same instance + assert result == widget_instance + + qtbot.wait(200) + + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + def test_dock_map(self, advanced_dock_area, qtbot): + """Test dock_map returns correct mapping.""" + # Initially empty + dock_map = advanced_dock_area.dock_map() + assert isinstance(dock_map, dict) + initial_count = len(dock_map) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock map updated + new_dock_map = advanced_dock_area.dock_map() + assert len(new_dock_map) == initial_count + 1 + + def test_dock_list(self, advanced_dock_area, qtbot): + """Test dock_list returns list of docks.""" + dock_list = advanced_dock_area.dock_list() + assert isinstance(dock_list, list) + initial_count = len(dock_list) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock list updated + new_dock_list = advanced_dock_area.dock_list() + assert len(new_dock_list) == initial_count + 1 + + def test_widget_map(self, advanced_dock_area, qtbot): + """Test widget_map returns widget mapping.""" + widget_map = advanced_dock_area.widget_map() + assert isinstance(widget_map, dict) + initial_count = len(widget_map) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget map updated + new_widget_map = advanced_dock_area.widget_map() + assert len(new_widget_map) == initial_count + 1 + + def test_widget_list(self, advanced_dock_area, qtbot): + """Test widget_list returns list of widgets.""" + widget_list = advanced_dock_area.widget_list() + assert isinstance(widget_list, list) + initial_count = len(widget_list) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget list updated + new_widget_list = advanced_dock_area.widget_list() + assert len(new_widget_list) == initial_count + 1 + + def test_delete_all(self, advanced_dock_area, qtbot): + """Test delete_all functionality.""" + # Create multiple widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(200) + + initial_count = len(advanced_dock_area.dock_list()) + assert initial_count >= 2 + + # Delete all + advanced_dock_area.delete_all() + + # Wait for deletion to complete + qtbot.wait(200) + + # Should have no docks + assert len(advanced_dock_area.dock_list()) == 0 + + +class TestWorkspaceLocking: + """Test workspace locking functionality.""" + + def test_lock_workspace_property_getter(self, advanced_dock_area): + """Test lock_workspace property getter.""" + # Initially unlocked + assert advanced_dock_area.lock_workspace is False + + # Set locked state directly + advanced_dock_area._locked = True + assert advanced_dock_area.lock_workspace is True + + def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): + """Test lock_workspace property setter.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Initially unlocked + assert advanced_dock_area.lock_workspace is False + + # Lock workspace + advanced_dock_area.lock_workspace = True + assert advanced_dock_area._locked is True + assert advanced_dock_area.lock_workspace is True + + # Unlock workspace + advanced_dock_area.lock_workspace = False + assert advanced_dock_area._locked is False + assert advanced_dock_area.lock_workspace is False + + +class TestDeveloperMode: + """Test developer mode functionality.""" + + def test_developer_mode_toggle(self, advanced_dock_area): + """Test developer mode toggle functionality.""" + # Check initial state + initial_editable = advanced_dock_area._editable + + # Toggle developer mode + advanced_dock_area._on_developer_mode_toggled(True) + assert advanced_dock_area._editable is True + assert advanced_dock_area.lock_workspace is False + + advanced_dock_area._on_developer_mode_toggled(False) + assert advanced_dock_area._editable is False + assert advanced_dock_area.lock_workspace is True + + def test_set_editable(self, advanced_dock_area): + """Test _set_editable functionality.""" + # Test setting editable to True + advanced_dock_area._set_editable(True) + assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area._editable is True + + # Test setting editable to False + advanced_dock_area._set_editable(False) + assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area._editable is False + + +class TestToolbarFunctionality: + """Test toolbar setup and functionality.""" + + def test_toolbar_setup(self, advanced_dock_area): + """Test toolbar is properly set up.""" + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "_ACTION_MAPPINGS") + + # Check that action mappings are properly set + assert "menu_plots" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_devices" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_utils" in advanced_dock_area._ACTION_MAPPINGS + + def test_toolbar_plot_actions(self, advanced_dock_area): + """Test plot toolbar actions trigger widget creation.""" + plot_actions = [ + "waveform", + "scatter_waveform", + "multi_waveform", + "image", + "motor_map", + "heatmap", + ] + + for action_name in plot_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_plots = advanced_dock_area.toolbar.components.get_action("menu_plots") + action = menu_plots.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_plots"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_device_actions(self, advanced_dock_area): + """Test device toolbar actions trigger widget creation.""" + device_actions = ["scan_control", "positioner_box"] + + for action_name in device_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_devices = advanced_dock_area.toolbar.components.get_action("menu_devices") + action = menu_devices.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_devices"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_utils_actions(self, advanced_dock_area): + """Test utils toolbar actions trigger widget creation.""" + utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"] + + for action_name in utils_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_utils = advanced_dock_area.toolbar.components.get_action("menu_utils") + action = menu_utils.actions[action_name].action + + # Skip log_panel as it's disabled + if action_name == "log_panel": + assert not action.isEnabled() + continue + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2] + + action.trigger() + if action_name == "terminal": + mock_new.assert_called_once_with( + widget="WebConsole", closable=True, startup_cmd=None + ) + else: + mock_new.assert_called_once_with(widget=widget_type) + + def test_attach_all_action(self, advanced_dock_area, qtbot): + """Test attach_all toolbar action.""" + # Create floating docks + advanced_dock_area.new("DarkModeButton", start_floating=True) + advanced_dock_area.new("DarkModeButton", start_floating=True) + + qtbot.wait(200) + + initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + + # Trigger attach all action + action = advanced_dock_area.toolbar.components.get_action("attach_all").action + action.trigger() + + # Wait a bit for the operation + qtbot.wait(200) + + # Should have fewer or same floating widgets + final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + assert final_floating <= initial_floating + + def test_screenshot_action(self, advanced_dock_area, tmpdir): + """Test screenshot toolbar action.""" + # Create a test screenshot file path in tmpdir + screenshot_path = tmpdir.join("test_screenshot.png") + + # Mock the QFileDialog.getSaveFileName to return a test filename + with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)") + + # Mock the screenshot.save method + with mock.patch.object(advanced_dock_area, "grab") as mock_grab: + mock_screenshot = mock.MagicMock() + mock_grab.return_value = mock_screenshot + + # Trigger the screenshot action + action = advanced_dock_area.toolbar.components.get_action("screenshot").action + action.trigger() + + # Verify the dialog was called + mock_dialog.assert_called_once() + + # Verify grab was called + mock_grab.assert_called_once() + + # Verify save was called with the filename + mock_screenshot.save.assert_called_once_with(str(screenshot_path)) + + +class TestDockSettingsDialog: + """Test dock settings dialog functionality.""" + + def test_dock_settings_dialog_init(self, advanced_dock_area): + """Test DockSettingsDialog initialization.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + mock_widget = DarkModeButton(parent=advanced_dock_area) + dialog = DockSettingsDialog(advanced_dock_area, mock_widget) + + assert dialog.windowTitle() == "Dock Settings" + assert dialog.isModal() + assert hasattr(dialog, "prop_editor") + + def test_open_dock_settings_dialog(self, advanced_dock_area, qtbot): + """Test opening dock settings dialog.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + # Create a real dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Mock dialog exec to avoid blocking + with patch.object(DockSettingsDialog, "exec") as mock_exec: + mock_exec.return_value = QDialog.Accepted + + # Call the method + advanced_dock_area._open_dock_settings_dialog(dock, widget) + + # Verify dialog was created and exec called + mock_exec.assert_called_once() + + +class TestSaveProfileDialog: + """Test save profile dialog functionality.""" + + def test_save_profile_dialog_init(self, qtbot): + """Test SaveProfileDialog initialization.""" + dialog = SaveProfileDialog(None, "test_profile") + qtbot.addWidget(dialog) + + assert dialog.windowTitle() == "Save Workspace Profile" + assert dialog.isModal() + assert dialog.name_edit.text() == "test_profile" + assert hasattr(dialog, "readonly_checkbox") + + def test_save_profile_dialog_get_values(self, qtbot): + """Test getting values from SaveProfileDialog.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + dialog.name_edit.setText("my_profile") + dialog.readonly_checkbox.setChecked(True) + + assert dialog.get_profile_name() == "my_profile" + assert dialog.is_readonly() is True + + def test_save_button_enabled_state(self, qtbot): + """Test save button is enabled/disabled based on name input.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + # Initially should be disabled (empty name) + assert not dialog.save_btn.isEnabled() + + # Should be enabled when name is entered + dialog.name_edit.setText("test") + assert dialog.save_btn.isEnabled() + + # Should be disabled when name is cleared + dialog.name_edit.setText("") + assert not dialog.save_btn.isEnabled() + + +class TestProfileManagement: + """Test profile management functionality.""" + + def test_profile_path(self, temp_profile_dir): + """Test profile path generation.""" + path = profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "test_profile.ini") + assert path == expected + + def test_open_settings(self, temp_profile_dir): + """Test opening settings for a profile.""" + settings = open_settings("test_profile") + assert isinstance(settings, QSettings) + + def test_list_profiles_empty(self, temp_profile_dir): + """Test listing profiles when directory is empty.""" + profiles = list_profiles() + assert profiles == [] + + def test_list_profiles_with_files(self, temp_profile_dir): + """Test listing profiles with existing files.""" + # Create some test profile files + profile_names = ["profile1", "profile2", "profile3"] + for name in profile_names: + settings = open_settings(name) + settings.setValue("test", "value") + settings.sync() + + profiles = list_profiles() + assert sorted(profiles) == sorted(profile_names) + + def test_readonly_profile_operations(self, temp_profile_dir): + """Test read-only profile functionality.""" + profile_name = "readonly_profile" + + # Initially should not be read-only + assert not is_profile_readonly(profile_name) + + # Set as read-only + set_profile_readonly(profile_name, True) + assert is_profile_readonly(profile_name) + + # Unset read-only + set_profile_readonly(profile_name, False) + assert not is_profile_readonly(profile_name) + + def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): + """Test writing and reading dock manifest.""" + settings = open_settings("test_manifest") + + # Create real docks + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(1000) + + docks = advanced_dock_area.dock_list() + + # Write manifest + write_manifest(settings, docks) + settings.sync() + + # Read manifest + items = read_manifest(settings) + + assert len(items) >= 3 + for item in items: + assert "object_name" in item + assert "widget_class" in item + assert "closable" in item + assert "floatable" in item + assert "movable" in item + + +class TestWorkspaceProfileOperations: + """Test workspace profile save/load/delete operations.""" + + def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): + """Test saving profile when read-only profile exists.""" + profile_name = "readonly_profile" + + # Create a read-only profile + set_profile_readonly(profile_name, True) + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog" + ) as mock_dialog_class: + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_profile_name.return_value = profile_name + mock_dialog.is_readonly.return_value = False + mock_dialog_class.return_value = mock_dialog + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" + ) as mock_warning: + mock_warning.return_value = QMessageBox.No + + advanced_dock_area.save_profile() + + mock_warning.assert_called_once() + + def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): + """Test loading profile with widget manifest.""" + profile_name = "test_load_profile" + + # Create a profile with manifest + settings = open_settings(profile_name) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "test_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + initial_count = len(advanced_dock_area.widget_map()) + + # Load profile + advanced_dock_area.load_profile(profile_name) + + # Wait for widget to be created + qtbot.wait(1000) + + # Check widget was created + widget_map = advanced_dock_area.widget_map() + assert "test_widget" in widget_map + + def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): + """Test deleting read-only profile shows warning.""" + profile_name = "readonly_profile" + + # Create read-only profile + set_profile_readonly(profile_name, True) + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" + ) as mock_warning: + advanced_dock_area.delete_profile() + + mock_warning.assert_called_once() + # Profile should still exist + assert os.path.exists(profile_path(profile_name)) + + def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): + """Test successful profile deletion.""" + profile_name = "deletable_profile" + + # Create regular profile + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + + with patch.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh: + advanced_dock_area.delete_profile() + + mock_question.assert_called_once() + mock_refresh.assert_called_once() + # Profile should be deleted + assert not os.path.exists(profile_path(profile_name)) + + def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): + """Test refreshing workspace list.""" + # Create some profiles + for name in ["profile1", "profile2"]: + settings = open_settings(name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.refresh_profiles = MagicMock() + mock_get_action.return_value.widget = mock_combo + + advanced_dock_area._refresh_workspace_list() + + mock_combo.refresh_profiles.assert_called_once() + + +class TestCleanupAndMisc: + """Test cleanup and miscellaneous functionality.""" + + def test_delete_dock(self, advanced_dock_area, qtbot): + """Test _delete_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + initial_count = len(advanced_dock_area.dock_list()) + + # Delete the dock + advanced_dock_area._delete_dock(dock) + + # Wait for deletion to complete + qtbot.wait(200) + + # Verify dock was removed + assert len(advanced_dock_area.dock_list()) == initial_count - 1 + + def test_apply_dock_lock(self, advanced_dock_area, qtbot): + """Test _apply_dock_lock functionality.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Test locking + advanced_dock_area._apply_dock_lock(True) + # No assertion needed - just verify it doesn't crash + + # Test unlocking + advanced_dock_area._apply_dock_lock(False) + # No assertion needed - just verify it doesn't crash + + def test_make_dock(self, advanced_dock_area): + """Test _make_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + initial_count = len(advanced_dock_area.dock_list()) + + # Create dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Verify dock was created + assert dock is not None + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + assert dock.widget() == widget + + def test_install_dock_settings_action(self, advanced_dock_area): + """Test _install_dock_settings_action functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Verify dock has settings action + assert hasattr(dock, "setting_action") + assert dock.setting_action is not None + + # Verify title bar actions were set + title_bar_actions = dock.titleBarActions() + assert len(title_bar_actions) >= 1 + + +class TestModeSwitching: + """Test mode switching functionality.""" + + def test_mode_property_setter_valid_modes(self, advanced_dock_area): + """Test setting valid modes.""" + valid_modes = ["plot", "device", "utils", "developer", "user"] + + for mode in valid_modes: + advanced_dock_area.mode = mode + assert advanced_dock_area.mode == mode + + def test_mode_changed_signal_emission(self, advanced_dock_area, qtbot): + """Test that mode_changed signal is emitted when mode changes.""" + # Set up signal spy + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = "plot" + + # Check signal was emitted with correct argument + assert blocker.args == ["plot"] + + +class TestToolbarModeBundles: + """Test toolbar bundle creation and visibility for different modes.""" + + def test_flat_bundles_created(self, advanced_dock_area): + """Test that flat bundles are created during toolbar setup.""" + # Check that flat bundles exist + assert "flat_plots" in advanced_dock_area.toolbar.bundles + assert "flat_devices" in advanced_dock_area.toolbar.bundles + assert "flat_utils" in advanced_dock_area.toolbar.bundles + + def test_plot_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in plot mode.""" + advanced_dock_area.mode = "plot" + + # Should show only flat_plots bundle (and essential bundles in real implementation) + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_plots" in shown_bundles + + # Should not show other flat bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + # Should not show menu bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + + def test_device_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in device mode.""" + advanced_dock_area.mode = "device" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_devices" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_utils" not in shown_bundles + + def test_utils_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in utils mode.""" + advanced_dock_area.mode = "utils" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_utils" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + + def test_developer_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in developer mode.""" + advanced_dock_area.mode = "developer" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show menu bundles + assert "menu_plots" in shown_bundles + assert "menu_devices" in shown_bundles + assert "menu_utils" in shown_bundles + + # Should show essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_user_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in user mode.""" + advanced_dock_area.mode = "user" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show only essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + # Should not show any widget creation bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + +class TestFlatToolbarActions: + """Test flat toolbar actions functionality.""" + + def test_flat_plot_actions_created(self, advanced_dock_area): + """Test that flat plot actions are created.""" + plot_actions = [ + "flat_waveform", + "flat_scatter_waveform", + "flat_multi_waveform", + "flat_image", + "flat_motor_map", + "flat_heatmap", + ] + + for action_name in plot_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_device_actions_created(self, advanced_dock_area): + """Test that flat device actions are created.""" + device_actions = ["flat_scan_control", "flat_positioner_box"] + + for action_name in device_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_utils_actions_created(self, advanced_dock_area): + """Test that flat utils actions are created.""" + utils_actions = [ + "flat_queue", + "flat_status", + "flat_progress_bar", + "flat_terminal", + "flat_bec_shell", + "flat_log_panel", + "flat_sbb_monitor", + ] + + for action_name in utils_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat plot actions trigger widget creation.""" + plot_action_mapping = { + "flat_waveform": "Waveform", + "flat_scatter_waveform": "ScatterWaveform", + "flat_multi_waveform": "MultiWaveform", + "flat_image": "Image", + "flat_motor_map": "MotorMap", + "flat_heatmap": "Heatmap", + } + + for action_name, widget_type in plot_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat device actions trigger widget creation.""" + device_action_mapping = { + "flat_scan_control": "ScanControl", + "flat_positioner_box": "PositionerBox", + } + + for action_name, widget_type in device_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat utils actions trigger widget creation.""" + utils_action_mapping = { + "flat_queue": "BECQueue", + "flat_status": "BECStatusBox", + "flat_progress_bar": "RingProgressBar", + "flat_terminal": "WebConsole", + "flat_bec_shell": "WebConsole", + "flat_sbb_monitor": "SBBMonitor", + } + + for action_name, widget_type in utils_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + + # Skip log_panel as it's disabled + if action_name == "flat_log_panel": + assert not action.isEnabled() + continue + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_log_panel_action_disabled(self, advanced_dock_area): + """Test that flat log panel action is disabled.""" + action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action + assert not action.isEnabled() + + +class TestModeTransitions: + """Test mode transitions and state consistency.""" + + def test_mode_transition_sequence(self, advanced_dock_area, qtbot): + """Test sequence of mode transitions.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for mode in modes: + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] + + def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): + """Test mode consistency after multiple rapid changes.""" + # Rapidly change modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "utils" + advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "user" + + # Final state should be consistent + assert advanced_dock_area.mode == "user" + + # Toolbar should show correct bundles for user mode + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_toolbar_refresh_on_mode_change(self, advanced_dock_area): + """Test that toolbar is properly refreshed when mode changes.""" + initial_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Change to a different mode + advanced_dock_area.mode = "plot" + plot_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Bundles should be different + assert initial_bundles != plot_bundles + assert "flat_plots" in plot_bundles + + def test_mode_switching_preserves_existing_docks(self, advanced_dock_area, qtbot): + """Test that mode switching doesn't affect existing docked widgets.""" + # Create some widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + initial_dock_count = len(advanced_dock_area.dock_list()) + initial_widget_count = len(advanced_dock_area.widget_list()) + + # Switch modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "user" + + # Dock and widget counts should remain the same + assert len(advanced_dock_area.dock_list()) == initial_dock_count + assert len(advanced_dock_area.widget_list()) == initial_widget_count + + +class TestModeProperty: + """Test mode property getter and setter behavior.""" + + def test_mode_property_getter(self, advanced_dock_area): + """Test mode property getter returns correct value.""" + # Set internal mode directly and test getter + advanced_dock_area._mode = "plot" + assert advanced_dock_area.mode == "plot" + + advanced_dock_area._mode = "device" + assert advanced_dock_area.mode == "device" + + def test_mode_property_setter_updates_internal_state(self, advanced_dock_area): + """Test mode property setter updates internal state.""" + advanced_dock_area.mode = "plot" + assert advanced_dock_area._mode == "plot" + + advanced_dock_area.mode = "utils" + assert advanced_dock_area._mode == "utils" + + def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): + """Test mode property setter triggers toolbar update.""" + with patch.object(advanced_dock_area.toolbar, "show_bundles") as mock_show_bundles: + advanced_dock_area.mode = "plot" + mock_show_bundles.assert_called_once() + + def test_multiple_mode_changes(self, advanced_dock_area, qtbot): + """Test multiple rapid mode changes.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for i, mode in enumerate(modes): + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] From 03015a72a6d34380c6f33ffbdbd7b8bba483156c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 10:50:56 +0200 Subject: [PATCH 011/155] fix(bec_widgets): adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects --- bec_widgets/cli/server.py | 7 + .../jupyter_console/jupyter_console_window.py | 2 + bec_widgets/utils/bec_widget.py | 19 +-- bec_widgets/utils/colors.py | 121 +++--------------- bec_widgets/utils/compact_popup.py | 6 +- bec_widgets/utils/round_frame.py | 65 +++------- bec_widgets/utils/toolbars/actions.py | 2 + bec_widgets/utils/toolbars/toolbar.py | 4 +- .../advanced_dock_area/advanced_dock_area.py | 2 + .../widgets/containers/dock/dock_area.py | 4 +- .../containers/main_window/main_window.py | 15 ++- .../buttons/button_abort/button_abort.py | 3 - .../buttons/stop_button/stop_button.py | 4 +- .../positioner_box/positioner_box.py | 4 +- .../positioner_box_2d/positioner_box_2d.py | 4 +- .../device_combobox/device_combobox.py | 26 +--- .../device_line_edit/device_line_edit.py | 4 +- .../signal_combobox/signal_combobox.py | 4 +- .../signal_line_edit/signal_line_edit.py | 4 +- .../control/scan_control/scan_control.py | 13 +- .../dap/dap_combo_box/dap_combo_box.py | 4 +- .../widgets/editors/dict_backed_table.py | 4 +- .../editors/scan_metadata/scan_metadata.py | 4 +- bec_widgets/widgets/games/minesweeper.py | 4 +- .../widgets/plots/motor_map/motor_map.py | 4 +- bec_widgets/widgets/plots/plot_base.py | 4 +- .../scatter_waveform/scatter_waveform.py | 5 +- .../settings/curve_settings/curve_tree.py | 14 +- .../widgets/plots/waveform/waveform.py | 4 +- .../widgets/services/bec_queue/bec_queue.py | 11 +- .../services/bec_status_box/bec_status_box.py | 4 +- .../services/device_browser/device_browser.py | 4 +- .../device_item/device_config_dialog.py | 4 +- .../device_item/device_signal_display.py | 4 +- .../widgets/utility/logpanel/logpanel.py | 4 +- .../widgets/utility/spinner/spinner.py | 8 +- .../dark_mode_button/dark_mode_button.py | 6 +- .../SpinnerWidget/SpinnerWidget_darwin.png | Bin 9490 -> 10025 bytes .../SpinnerWidget/SpinnerWidget_linux.png | Bin 9490 -> 10387 bytes .../SpinnerWidget_started_darwin.png | Bin 14773 -> 14819 bytes tests/unit_tests/conftest.py | 7 + tests/unit_tests/test_abort_button.py | 4 - tests/unit_tests/test_color_utils.py | 13 ++ tests/unit_tests/test_round_frame.py | 12 -- 44 files changed, 164 insertions(+), 277 deletions(-) diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index ea45c61e1..d27a74f77 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -7,8 +7,10 @@ import sys from contextlib import redirect_stderr, redirect_stdout +import darkdetect from bec_lib.logger import bec_logger from bec_lib.service_config import ServiceConfig +from bec_qthemes import apply_theme from qtmonaco.pylsp_provider import pylsp_server from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QIcon @@ -92,6 +94,11 @@ def _run(self): Run the GUI server. """ self.app = QApplication(sys.argv) + if darkdetect.isDark(): + apply_theme("dark") + else: + apply_theme("light") + self.app.setApplicationName("BEC") self.app.gui_id = self.gui_id # type: ignore self.setup_bec_icon() diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 88a7dc44d..b5e925633 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -15,6 +15,7 @@ ) from bec_widgets.utils import BECDispatcher +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea @@ -168,6 +169,7 @@ def closeEvent(self, event): module_path = os.path.dirname(bec_widgets.__file__) app = QApplication(sys.argv) + apply_theme("dark") app.setApplicationName("Jupyter Console") app.setApplicationDisplayName("Jupyter Console") icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index dccd82ff5..67a4c5e9e 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import TYPE_CHECKING -import darkdetect import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger @@ -12,7 +11,6 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig -from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy @@ -47,8 +45,7 @@ def __init__( >>> class MyWidget(BECWidget, QWidget): >>> def __init__(self, parent=None, client=None, config=None, gui_id=None): - >>> super().__init__(client=client, config=config, gui_id=gui_id) - >>> QWidget.__init__(self, parent=parent) + >>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id) Args: @@ -64,14 +61,6 @@ def __init__( ) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - app = QApplication.instance() - if not hasattr(app, "theme"): - # DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault - # Instead, we will set the theme to the system setting on startup - if darkdetect.isDark(): - set_theme("dark") - else: - set_theme("light") if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") @@ -80,9 +69,11 @@ def __init__( def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() - if hasattr(qapp, "theme_signal"): - qapp.theme_signal.theme_updated.connect(self._update_theme) + if hasattr(qapp, "theme"): + qapp.theme.theme_changed.connect(self._update_theme) + @SafeSlot(str, verify_sender=True) + @SafeSlot(verify_sender=True) def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 9aa40c3ba..236cd157a 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -1,19 +1,17 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Literal +from typing import Literal -import bec_qthemes import numpy as np import pyqtgraph as pg -from bec_qthemes._os_appearance.listener import OSThemeSwitchListener +from bec_qthemes import apply_theme as apply_theme_global +from bec_qthemes._theme import AccentColors from pydantic_core import PydanticCustomError +from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication -if TYPE_CHECKING: # pragma: no cover - from bec_qthemes._main import AccentColors - def get_theme_name(): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): @@ -23,118 +21,35 @@ def get_theme_name(): def get_theme_palette(): - return bec_qthemes.load_palette(get_theme_name()) + # FIXME this is legacy code, should be removed in the future + app = QApplication.instance() + palette = app.palette() + return palette -def get_accent_colors() -> AccentColors | None: +def get_accent_colors() -> AccentColors: """ Get the accent colors for the current theme. These colors are extensions of the color palette and are used to highlight specific elements in the UI. """ if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): - return None + accent_colors = AccentColors() + return accent_colors return QApplication.instance().theme.accent_colors -def _theme_update_callback(): - """ - Internal callback function to update the theme based on the system theme. - """ - app = QApplication.instance() - # pylint: disable=protected-access - app.theme.theme = app.os_listener._theme.lower() - app.theme_signal.theme_updated.emit(app.theme.theme) - apply_theme(app.os_listener._theme.lower()) - - -def set_theme(theme: Literal["dark", "light", "auto"]): - """ - Set the theme for the application. - - Args: - theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme. - """ - app = QApplication.instance() - bec_qthemes.setup_theme(theme, install_event_filter=False) - - app.theme_signal.theme_updated.emit(theme) - apply_theme(theme) - - if theme != "auto": - return - - if not hasattr(app, "os_listener") or app.os_listener is None: - app.os_listener = OSThemeSwitchListener(_theme_update_callback) - app.installEventFilter(app.os_listener) +def process_all_deferred_deletes(qapp): + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents(QEventLoop.AllEvents) def apply_theme(theme: Literal["dark", "light"]): """ - Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead. + Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally. """ - app = QApplication.instance() - graphic_layouts = [ - child - for top in app.topLevelWidgets() - for child in top.findChildren(pg.GraphicsLayoutWidget) - ] - - plot_items = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.PlotItem) - ] - - histograms = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.HistogramLUTItem) - ] - - # Update background color based on the theme - if theme == "light": - background_color = "#e9ecef" # Subtle contrast for light mode - foreground_color = "#141414" - label_color = "#000000" - axis_color = "#666666" - else: - background_color = "#141414" # Dark mode - foreground_color = "#e9ecef" - label_color = "#FFFFFF" - axis_color = "#CCCCCC" - - # update GraphicsLayoutWidget - pg.setConfigOptions(foreground=foreground_color, background=background_color) - for pg_widget in graphic_layouts: - pg_widget.setBackground(background_color) - - # update PlotItems - for plot_item in plot_items: - for axis in ["left", "right", "top", "bottom"]: - plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color)) - plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color)) - - # Change title color - plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color) - - # Change legend color - if hasattr(plot_item, "legend") and plot_item.legend is not None: - plot_item.legend.setLabelTextColor(label_color) - # if legend is in plot item and theme is changed, has to be like that because of pg opt logic - for sample, label in plot_item.legend.items: - label_text = label.text - label.setText(label_text, color=label_color) - - # update HistogramLUTItem - for histogram in histograms: - histogram.axis.setPen(pg.mkPen(color=axis_color)) - histogram.axis.setTextPen(pg.mkPen(color=label_color)) - - # now define stylesheet according to theme and apply it - style = bec_qthemes.load_stylesheet(theme) - app.setStyleSheet(style) + process_all_deferred_deletes(QApplication.instance()) + apply_theme_global(theme) + process_all_deferred_deletes(QApplication.instance()) class Colors: diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index cb5203b8a..8d4daef24 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -11,6 +11,7 @@ QPushButton, QSizePolicy, QSpacerItem, + QToolButton, QVBoxLayout, QWidget, ) @@ -122,15 +123,14 @@ def __init__(self, parent=None, layout=QVBoxLayout): self.compact_view_widget = QWidget(self) self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) QHBoxLayout(self.compact_view_widget) - self.compact_view_widget.layout().setSpacing(0) + self.compact_view_widget.layout().setSpacing(5) self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0) self.compact_view_widget.layout().addSpacerItem( QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed) ) self.compact_label = QLabel(self.compact_view_widget) self.compact_status = LedLabel(self.compact_view_widget) - self.compact_show_popup = QPushButton(self.compact_view_widget) - self.compact_show_popup.setFlat(True) + self.compact_show_popup = QToolButton(self.compact_view_widget) self.compact_show_popup.setIcon( material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False) ) diff --git a/bec_widgets/utils/round_frame.py b/bec_widgets/utils/round_frame.py index 51ec34979..f8399d1b3 100644 --- a/bec_widgets/utils/round_frame.py +++ b/bec_widgets/utils/round_frame.py @@ -1,11 +1,12 @@ import pyqtgraph as pg -from qtpy.QtCore import Property +from qtpy.QtCore import Property, Qt from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton class RoundedFrame(QFrame): + # TODO this should be removed completely in favor of QSS styling, no time now """ A custom QFrame with rounded corners and optional theme updates. The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets. @@ -28,6 +29,9 @@ def __init__( self.setProperty("skip_settings", True) self.setObjectName("roundedFrame") + # Ensure QSS can paint background/border on this widget + self.setAttribute(Qt.WA_StyledBackground, True) + # Create a layout for the frame if orientation == "vertical": self.layout = QVBoxLayout(self) @@ -45,22 +49,10 @@ def __init__( # Automatically apply initial styles to the GraphicalLayoutWidget if applicable self.apply_plot_widget_style() + self.update_style() def apply_theme(self, theme: str): - """ - Apply the theme to the frame and its content if theme updates are enabled. - """ - if self.content_widget is not None and isinstance( - self.content_widget, pg.GraphicsLayoutWidget - ): - self.content_widget.setBackground(self.background_color) - - # Update background color based on the theme - if theme == "light": - self.background_color = "#e9ecef" # Subtle contrast for light mode - else: - self.background_color = "#141414" # Dark mode - + """Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven.""" self.update_style() @Property(int) @@ -77,34 +69,21 @@ def update_style(self): """ Update the style of the frame based on the background color. """ - if self.background_color: - self.setStyleSheet( - f""" + self.setStyleSheet( + f""" QFrame#roundedFrame {{ - background-color: {self.background_color}; - border-radius: {self._radius}; /* Rounded corners */ + border-radius: {self._radius}px; }} """ - ) + ) self.apply_plot_widget_style() def apply_plot_widget_style(self, border: str = "none"): """ - Automatically apply background, border, and axis styles to the PlotWidget. - - Args: - border (str): Border style (e.g., 'none', '1px solid red'). + Let QSS/pyqtgraph handle plot styling; avoid overriding here. """ if isinstance(self.content_widget, pg.GraphicsLayoutWidget): - # Apply border style via stylesheet - self.content_widget.setStyleSheet( - f""" - GraphicsLayoutWidget {{ - border: {border}; /* Explicitly set the border */ - }} - """ - ) - self.content_widget.setBackground(self.background_color) + self.content_widget.setStyleSheet("") class ExampleApp(QWidget): # pragma: no cover @@ -128,24 +107,14 @@ def __init__(self): plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r") plot2.plot_item = plot_item_2 - # Wrap PlotWidgets in RoundedFrame - rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1) - rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2) - - # Add to layout + # Add to layout (no RoundedFrame wrapper; QSS styles plots) layout.addWidget(dark_button) - layout.addWidget(rounded_plot1) - layout.addWidget(rounded_plot2) + layout.addWidget(plot1) + layout.addWidget(plot2) self.setLayout(layout) - from qtpy.QtCore import QTimer - - def change_theme(): - rounded_plot1.apply_theme("light") - rounded_plot2.apply_theme("dark") - - QTimer.singleShot(100, change_theme) + # Theme flip demo removed; global theming applies automatically if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4e915cb85..4278877b1 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -446,6 +446,8 @@ def __init__(self, label: str, actions: dict, icon_path: str = None): def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): button = QToolButton(toolbar) + button.setObjectName("toolbarMenuButton") + button.setAutoRaise(True) if self.icon_path: button.setIcon(QIcon(self.icon_path)) button.setText(self.tooltip) diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index 21b3c7107..c1b7b7f28 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -10,7 +10,7 @@ from qtpy.QtGui import QAction, QColor from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget -from bec_widgets.utils.colors import get_theme_name, set_theme +from bec_widgets.utils.colors import apply_theme, get_theme_name from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection @@ -507,7 +507,7 @@ def enable_fps_monitor(self, enabled: bool): self.test_label.setText("FPS Monitor Disabled") app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") main_window = MainWindow() main_window.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index fda1613fa..27a05299f 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -171,6 +171,7 @@ def __init__( # Init Dock Manager self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") # Dock manager helper variables self._locked = False # Lock state of the workspace @@ -928,4 +929,5 @@ def cleanup(self): window.setCentralWidget(ads) window.show() window.resize(800, 600) + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py index ca6a698b1..50210b24e 100644 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ b/bec_widgets/widgets/containers/dock/dock_area.py @@ -616,10 +616,10 @@ def remove(self) -> None: import sys - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("auto") + apply_theme("dark") dock_area = BECDockArea() dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton") dock_1.new(widget="DarkModeButton") diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 054e0bc91..cead68c10 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -19,7 +19,7 @@ import bec_widgets from bec_widgets.utils import UILoader from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import apply_theme, set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget @@ -320,11 +320,12 @@ def _setup_menu_bar(self): dark_theme_action.triggered.connect(lambda: self.change_theme("dark")) # Set the default theme - theme = self.app.theme.theme - if theme == "light": - light_theme_action.setChecked(True) - elif theme == "dark": - dark_theme_action.setChecked(True) + if hasattr(self.app, "theme") and self.app.theme: + theme_name = self.app.theme.theme.lower() + if "light" in theme_name: + light_theme_action.setChecked(True) + elif "dark" in theme_name: + dark_theme_action.setChecked(True) ######################################## # Help menu @@ -394,7 +395,7 @@ def change_theme(self, theme: str): Args: theme(str): Either "light" or "dark". """ - set_theme(theme) # emits theme_updated and applies palette globally + apply_theme(theme) # emits theme_updated and applies palette globally def event(self, event): if event.type() == QEvent.Type.StatusTip: diff --git a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py index c14bb062e..7adc8d4ca 100644 --- a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py +++ b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py @@ -38,9 +38,6 @@ def __init__( else: self.button = QPushButton() self.button.setText("Abort") - self.button.setStyleSheet( - "background-color: #666666; color: white; font-weight: bold; font-size: 12px;" - ) self.button.clicked.connect(self.abort_scan) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py index 7d0456ecc..fdedf4f1f 100644 --- a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py +++ b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py @@ -31,9 +31,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=F self.button = QPushButton() self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.button.setText("Stop") - self.button.setStyleSheet( - f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;" - ) + self.button.setProperty("variant", "danger") self.button.clicked.connect(self.stop_scan) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 4a686d8cf..a573623e6 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -12,7 +12,7 @@ from qtpy.QtWidgets import QDoubleSpinBox from bec_widgets.utils import UILoader -from bec_widgets.utils.colors import get_accent_colors, set_theme +from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( @@ -259,7 +259,7 @@ def on_setpoint_change(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = PositionerBox(device="bpm4i") widget.show() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index f0853e5b6..298a2f076 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -13,7 +13,7 @@ from qtpy.QtWidgets import QDoubleSpinBox from bec_widgets.utils import UILoader -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import ( @@ -531,7 +531,7 @@ def on_setpoint_change_ver(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = PositionerBox2D() widget.show() diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index b80227beb..f6bca8d4b 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -147,24 +147,6 @@ def get_current_device(self) -> object: dev_name = self.currentText() return self.get_device_object(dev_name) - def paintEvent(self, event: QPaintEvent) -> None: - """Extend the paint event to set the border color based on the validity of the input. - - Args: - event (PySide6.QtGui.QPaintEvent) : Paint event. - """ - # logger.info(f"Received paint event: {event} in {self.__class__}") - super().paintEvent(event) - - if self._is_valid_input is False and self.isEnabled() is True: - painter = QPainter(self) - pen = QPen() - pen.setWidth(2) - pen.setColor(self._accent_colors.emergency) - painter.setPen(pen) - painter.drawRect(self.rect().adjusted(1, 1, -1, -1)) - painter.end() - @Slot(str) def check_validity(self, input_text: str) -> None: """ @@ -173,10 +155,12 @@ def check_validity(self, input_text: str) -> None: if self.validate_device(input_text) is True: self._is_valid_input = True self.device_selected.emit(input_text) + self.setStyleSheet("border: 1px solid transparent;") else: self._is_valid_input = False self.device_reset.emit() - self.update() + if self.isEnabled(): + self.setStyleSheet("border: 1px solid red;") def validate_device(self, device: str) -> bool: # type: ignore[override] """ @@ -202,10 +186,10 @@ def validate_device(self, device: str) -> bool: # type: ignore[override] # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py index 3a0f1925c..e9d523fd8 100644 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +++ b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py @@ -175,13 +175,13 @@ def check_validity(self, input_text: str) -> None: # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( SignalComboBox, ) app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 7c0bdaddb..134bc8360 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -179,10 +179,10 @@ def selected_signal_comp_name(self) -> str: # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py index 4c8ecb0d9..759706a1a 100644 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +++ b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py @@ -147,13 +147,13 @@ def on_text_changed(self, text: str): # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( DeviceComboBox, ) app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 27bad0234..8948e4ef5 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -20,7 +20,7 @@ from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox @@ -136,13 +136,8 @@ def _init_UI(self): self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.button_layout = QHBoxLayout(self.scan_control_group) self.button_run_scan = QPushButton("Start", self.scan_control_group) - self.button_run_scan.setStyleSheet( - f"background-color: {palette.success.name()}; color: white" - ) + self.button_run_scan.setProperty("variant", "success") self.button_stop_scan = StopButton(parent=self.scan_control_group) - self.button_stop_scan.setStyleSheet( - f"background-color: {palette.emergency.name()}; color: white" - ) self.button_layout.addWidget(self.button_run_scan) self.button_layout.addWidget(self.button_stop_scan) self.layout.addWidget(self.scan_control_group) @@ -547,12 +542,10 @@ def cleanup(self): # Application example if __name__ == "__main__": # pragma: no cover - from bec_widgets.utils.colors import set_theme - app = QApplication([]) scan_control = ScanControl() - set_theme("auto") + apply_theme("dark") window = scan_control window.show() app.exec() diff --git a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py index c9892ba3c..5931d6726 100644 --- a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +++ b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py @@ -175,10 +175,10 @@ def _validate_dap_model(self, model: str | None) -> bool: # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) layout = QVBoxLayout() diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 3efe3887f..a9fb0644f 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -249,10 +249,10 @@ def autoscale(self, autoscale: bool): if __name__ == "__main__": # pragma: no cover - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("dark") + apply_theme("dark") window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) window.show() diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 3cee7b069..bd75081d6 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -96,7 +96,7 @@ def set_schema_from_scan(self, scan_name: str | None): from bec_lib.metadata_schema import BasicScanMetadata - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme class ExampleSchema1(BasicScanMetadata): abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C") @@ -140,7 +140,7 @@ class ExampleSchema3(BasicScanMetadata): layout.addWidget(selection) layout.addWidget(scan_metadata) - set_theme("dark") + apply_theme("dark") window = w window.show() app.exec() diff --git a/bec_widgets/widgets/games/minesweeper.py b/bec_widgets/widgets/games/minesweeper.py index 607cde57c..ad9e496f6 100644 --- a/bec_widgets/widgets/games/minesweeper.py +++ b/bec_widgets/widgets/games/minesweeper.py @@ -407,10 +407,10 @@ def cleanup(self): if __name__ == "__main__": - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication([]) - set_theme("light") + apply_theme("light") widget = Minesweeper() widget.show() diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index 0241ce103..61a4764c8 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -11,7 +11,7 @@ from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from bec_widgets.utils import Colors, ConnectionConfig -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbars.toolbar import MaterialIconAction @@ -790,7 +790,7 @@ def __init__(self): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index ea8bcf075..823ac6de5 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -179,7 +179,7 @@ def __init__( self._init_ui() self._connect_to_theme_change() - self._update_theme() + self._update_theme(None) def apply_theme(self, theme: str): self.round_plot_widget.apply_theme(theme) @@ -187,6 +187,8 @@ def apply_theme(self, theme: str): def _init_ui(self): self.layout.addWidget(self.layout_manager) self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget) + self.round_plot_widget.setProperty("variant", "plot_background") + self.round_plot_widget.setProperty("frameless", True) self.layout_manager.add_widget(self.round_plot_widget) self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top") diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 3ed5ea653..28ae9d7f5 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -10,7 +10,6 @@ from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from bec_widgets.utils import Colors, ConnectionConfig -from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbars.toolbar import MaterialIconAction @@ -504,8 +503,10 @@ def __init__(self): from qtpy.QtWidgets import QApplication + from bec_widgets.utils.colors import apply_theme + app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index a5951cf31..ac4469799 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -6,6 +6,7 @@ from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtGui import QValidator +from qtpy.QtWidgets import QApplication class ScanIndexValidator(QValidator): @@ -34,6 +35,7 @@ def validate(self, input_str: str, pos: int): from qtpy.QtWidgets import ( + QApplication, QComboBox, QHBoxLayout, QHeaderView, @@ -97,6 +99,7 @@ def __init__( # A top-level device row. super().__init__(tree) + self.app = QApplication.instance() self.tree = tree self.parent_item = parent_item self.curve_tree = tree.parent() # The CurveTree widget @@ -194,7 +197,16 @@ def _init_actions(self): # If device row, add "Add DAP" button if self.source in ("device", "history"): - self.add_dap_button = QPushButton("DAP") + self.add_dap_button = QToolButton() + analysis_icon = material_icon( + "monitoring", + size=(20, 20), + convert_to_pixmap=False, + filled=False, + color=self.app.theme.colors["FG"].toTuple(), + ) + self.add_dap_button.setIcon(analysis_icon) + self.add_dap_button.setToolTip("Add DAP") self.add_dap_button.clicked.connect(lambda: self.add_dap_row()) actions_layout.addWidget(self.add_dap_button) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 3387320bc..51223ebd7 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -26,7 +26,7 @@ from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_signal_proxy import BECSignalProxy -from bec_widgets.utils.colors import Colors, set_theme +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog @@ -2384,7 +2384,7 @@ def _populate_custom_curve_demo(self): import sys app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 473ed3ef3..b2dff0d75 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -252,8 +252,15 @@ def _create_abort_button(self, scan_id: str) -> AbortButton: abort_button.button.setIcon( material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False) ) - abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ") - abort_button.button.setFlat(True) + abort_button.setStyleSheet( + """ + QPushButton { + background-color: transparent; + border: none; + } + """ + ) + return abort_button def delete_selected_row(self): diff --git a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py index ca22a2f68..e1b8948de 100644 --- a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py +++ b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py @@ -315,10 +315,10 @@ def cleanup(self): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") main_window = BECStatusBox() main_window.show() sys.exit(app.exec()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index 9b28607c1..be9382ead 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -242,10 +242,10 @@ def cleanup(self): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = DeviceBrowser() widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index 1ffd8fbb5..4df088a67 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -262,12 +262,12 @@ def main(): # pragma: no cover from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme dialog = None app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = QWidget() widget.setLayout(QVBoxLayout()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py index 30e53ad23..392c0a6dc 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py @@ -110,10 +110,10 @@ def device(self, value: str): from qtpy.QtWidgets import QApplication - from bec_widgets.utils.colors import set_theme + from bec_widgets.utils.colors import apply_theme app = QApplication(sys.argv) - set_theme("light") + apply_theme("light") widget = SignalDisplay(device="samx") widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/utility/logpanel/logpanel.py b/bec_widgets/widgets/utility/logpanel/logpanel.py index 76aca47d7..ad5dee294 100644 --- a/bec_widgets/widgets/utility/logpanel/logpanel.py +++ b/bec_widgets/widgets/utility/logpanel/logpanel.py @@ -35,7 +35,7 @@ ) from bec_widgets.utils.bec_connector import BECConnector -from bec_widgets.utils.colors import get_theme_palette, set_theme +from bec_widgets.utils.colors import apply_theme, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.text_box.text_box import TextBox from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin @@ -544,7 +544,7 @@ def cleanup(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = LogPanel() widget.show() diff --git a/bec_widgets/widgets/utility/spinner/spinner.py b/bec_widgets/widgets/utility/spinner/spinner.py index 099804af7..ab5dadd15 100644 --- a/bec_widgets/widgets/utility/spinner/spinner.py +++ b/bec_widgets/widgets/utility/spinner/spinner.py @@ -49,7 +49,7 @@ def rotate(self): def paintEvent(self, event): painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) size = min(self.width(), self.height()) rect = QRect(0, 0, size, size) @@ -63,14 +63,14 @@ def paintEvent(self, event): rect.adjust(line_width, line_width, -line_width, -line_width) # Background arc - painter.setPen(QPen(background_color, line_width, Qt.SolidLine)) + painter.setPen(QPen(background_color, line_width, Qt.PenStyle.SolidLine)) adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height()) painter.drawArc(adjusted_rect, 0, 360 * 16) if self._started: # Foreground arc - pen = QPen(color, line_width, Qt.SolidLine) - pen.setCapStyle(Qt.RoundCap) + pen = QPen(color, line_width, Qt.PenStyle.SolidLine) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) proportion = 1 / 4 angle_span = int(proportion * 360 * 16) diff --git a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py index e8f352e8d..6fdb1f15a 100644 --- a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +++ b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py @@ -5,7 +5,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import set_theme +from bec_widgets.utils.colors import apply_theme class DarkModeButton(BECWidget, QWidget): @@ -85,7 +85,7 @@ def toggle_dark_mode(self) -> None: """ self.dark_mode_enabled = not self.dark_mode_enabled self.update_mode_button() - set_theme("dark" if self.dark_mode_enabled else "light") + apply_theme("dark" if self.dark_mode_enabled else "light") def update_mode_button(self): icon = material_icon( @@ -100,7 +100,7 @@ def update_mode_button(self): if __name__ == "__main__": app = QApplication([]) - set_theme("auto") + apply_theme("dark") w = DarkModeButton() w.show() diff --git a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png index 2b75d66a8c62c33426eb1dab52a41e56486d9d5f..54bd8c5e39d495666dbbe18c914e24406b32b221 100644 GIT binary patch literal 10025 zcmXAPc_38n_xPQ`*bR|=DF&6XMb^QmkVYnz?1quDZ&^pS!Hd+JDY8US*6d?vh{0Q8 z?6QYJ_HFFhf7AE(-@Rvh&OPTj=Q+{_aA4SoQqA!i|5q>KdwIz)_Vyk{+zP>Sd+BzPr0+O z+0fV8g!5CoAOM(>3Ek0w0&2n>hXe9J*l^7{b#aEVErp|FsVwaL60+&6^#ID@agewv z&tZ3dU=B*YgtN4ms6h?F(1sk$nmO(Rr-!T3;ktpYNU?TZDKU=qH{ES1<87BwCYRZ} z41k^CPg=r6o$z_r!7jd1=+_+Wna$>Zf)Vciw5Kuy<&%=ng++y)nY{`2{+jPDu@@1UDsxI#H?LPsmn0;@x_8Q8@l-xe z8fo@^VEM%9@)+q3Q83$cE01}xu*_KbRDQEE(S6)cB3P}Y7*6KR(?|ySkE)7R7GJ+^ZV;TTiGju3;3f$Tn{Pgv#4cne zH@c*S8e_2|MGm~vI}gJU7xor(pvKpGC{wf3uiGP%u`qd``LdIAB`dQ7?V{?~#vki%Nfq?e=QmJ; z9ot}0_ z`<(bkY;D0z6!zlhd(XBPw0#(bsSq%BXwjErF}1$4jHop6kgctlp87au>1*}OFaKTM zP7}+zS$Sl_<(TO+BIcZ)x^mlf)Z5V`ec*c_Ntm8$J>-v0o-et;Ut~M{Rq!6ZJ^Mh~ zNFON68Zg|gNa7zZp*P_odAYX0$*avhSt+ufhJ5<960$Kf7r|It6gu->eo_0^o}p2{ zueW^e5Jgt=&DJj>T?ET!Q`mqkGa;`sK`6LDiJIL@&p4b0en;^@p z_^viBX8lf^JQL;MB4M`@#(Jv1R`IV6asZEq3b5h1NhXCv|IH*j?2H+WtCrS zNwDXEblK#(3TqED#fe3`4CNm%%ZgWB*vmA-fA7ZuVbxQe zaq=g#jklljGEt^Sto_YfLP9M5w3gMzKXT+!2c4ul#cFoUbj-$WXy;N^YqH}$0AZd7 z#dd#02Y*V?26hT$dQ~mP?7n7iCD&3eEx{q*jd^q{HFv0Gls2CaIxfpcohum1&M7xkqy-o1zgnp9`6 zk+G5ov(voE^>l&+qs23hzdiRx%q*mPWM zL?$n`m$rEAs<}ejV&`O1!dkWe6C$0ej5xh%q6IR)0<%|29y&2pVK$FU={=Xj$7009 zQE7$OjPp&X-{7kQ=F5z5abyA>k0-0-3@$%1b=4pH%un~n@2K&Eq9sqXx)7mmZfmXr zUIZr(F8h8qr}sr>!?7GrMxuCwT9($AofUF|oGU~#!S0@%^6t; z!$VG(A*FC<48l(T?6fawTDVvaXX-51DHC0Y{!|}%mOLJPpw{Df`e~)t zv}X=Qz)S`eJDrn_|A8_&&(}{ykA|Pvd^%@r0e^-Ixao$TvkKw_tGm(<>S_57}7^F>UHPixd0&&YVd=0 z+f8%JWONNbIK9t~EE3*`h zm6upl%C5xUgoq_X`72nz+wdM9`qtyy|G=L?^MPM&G;d09ziY*4vR~|tUcP0wF4&q; zMxaJl*3tn*1t3YbqaA_T|A{(x^eaPPm*r zDxsy=kXZhT_g=n2XoDNkz(5;tNrOnfpCmYUw-k3IgX`dA4CQwC&4WLWuj6c!{Rf?U zo3qqYO~;IYl=-Vyo^}zZZEtJQ8MAe)2wnNE4GZiMD;uDJ+cS z0ux@a(|Zppr^q%#JOSuR{@m*f|J?5|GYlO_n8#K!)^p)Qo&=@fW>UvxW|j)x&K5BFyvKMpC3I!5;d50Mb6Xc`qeba+=;aP4~s!6a&GikJ@YE{9tzRHQ1X?f7tJ`M z3Y3hSl#4;O^bD5ygUFk_cagQ%y{?;L85-egY@#d`MvE9O8ForbailJmNH!J#V32*6;Bj4Ylx3l<@-3KZQrXhcWLAf_;EajQpnlUVGA*F z=A{2#&UX57#J12v`>7>FNmW}rM_%C-EpZR3TihpBluq|A>}<9DCxej-^>@PZ7b~SM zf$JthI2yt#KRG}#R+h<(Ba-!hp?`LKmAU2AVUC`Fmj;_?AcPSwG3pE0?TawHehw(r z3oG^2ye@@hF;VhdBAX=8%_PmImb`wDRl`~Kfs6Yh^M^};VA8Mc#=ayy8<36ZQ{S3H z#IL=EB^j{zMMTKBuA4;KB!oKOS2XLqBVchmEk?EJC#|Z?-sVZ|OK{=a70E4ln@E~P zBeV10^b4;k&^Bdb<1_mLK05Ecn?Gw%rmvG(2q_bwFl6Qf_0ukU492!(s+7}p&fRo{o@Bf2;9<3?q5F*)H z(pOFaH75*NR`f#mI^>NqUwKSGbjRW05ux&t>3QW$j)#C8xj*!Max-azujMq_i?;>g zH0$i1_I+8Nb4GlWogFSrYWd2V-26YI`Mx~eoqw(lmM>eJf%|PuENIe=0LE_b(;xg~ zJ523>XRBRbLXLE*LGI4p8H5oAD1M-qiCvfk35RU>9Gwzz}QIxe*e3ZY(q|uzIx-M%l*W+W;dw+#ca zU77?Y27FT(eDjMEw-g5jvul;Hi1aawXW)#5A>}riZ8zo#2LOko@&2YxrF6s{1}G^{ z@mok_zlb;V8S#@^tgoSz=FI`a z@>DHc)T%ZM?O9bc?&jBPC)&7%W%Ie2-hyozT=T%Qt7d}%4&}U|`fFEkz2Rx|1AG+k z#g;3$_m^-JHS7yssbLJ9_g{Y71qtVr(Kzn9NL$bgv3D1NgpHaj&e^>6saQ7Aj5kfA zXF?(W(NR|B`X|{Mc}V-2ps-0!cfK@p@aUNmDSPdf5(=0IhhSf#o%5I}m>n3|=eQRL z9F2BN14;|G_6+`VTtHllV!Wyttc;tu=ey`20;EJf9;)$JyR~BjFiuDzw~t@_1Dbv7 zcN=Qd(8ng){eUK-*?})@nvptx=Bu#4gR_oIFm32VaFJ;C$XhqC+NdTJ032!Um0chO z5_e>K9@<%yh2nV-6q1_z~kvwSp?ch4Cli+MvEDXg!BO@9vRp#E+L|DdIAMJt& zr%t#<2B>j6>EuIRi=}=Zn3WQ=D1+fsqWS`i?03+>q9Cm*=0zaTT_h-|7{rXZmi87a zj$3_pS_z>Q!>pp8Tj-KUPpZJ8nGY>MbVfDc05;*;V9+n+*ED=Vj!T+wkK;qv+b<`+ z{*S%0O!*S1@s84k=>aDW+`0~3BMkdS0}?+rDZ8|F_z&|tK%PZ3$HReD!&X&kh~5xa zkB<;C=f1t>mTK$fSX0Mu;r(O z_%kibs6uQYKxcGiX=UyE*@a9Y8Om1J3sD&E(DP|u++PMR;koA^z;-~#)L>4HH&ng& zD$YPBr3pwB(yBQdxtfpa3hpl2LVdls9 zv}BuJ_WP$4LS4Gj%JTOed(*m)%W%^ilZz~*7sPNZM)w7Lmhj^u%?08(Fu3=1DGEOJ zO^|hG!%rZ`3kZxqofz$l2;jQ_U6=S50`qMJPBbmCP-y5WQzj1v;S_R6H5h`8rGSb! zHB?$4Kh#1|5fJQ%-jYlrZmhaN=+Xcm#%xC10%&hw@HiKsgs$CcUz=jFfL~3nF~4pBY8N8SK^-=3g0XH~ zW(0A^kpWE<>29MauFjbu-t74*b($6;n*Z{(tPp0m4Dx6fE649@g*6fdbRLLN$X3yJ;VAw_-eQcu$*>Q`>hJ{37L9Uq6F) z-N_yV!1B#~n)AyZ=-lR`c=(CyFCVe|jo}IG@9)dsd6fayt^dXU<7fw1-~b4Yfo@bl zfuw;wyUj~#ngL|$@i(QNe_8i6odRBSZ-pdi+7f_J60n-Ze)WPG31F>Uh)ev-m--I_ zK~D6?TLKt@n+K0F(~}IbmkemW26!*wK)efms`o}}KYbMfk{`2b{N*HM0sDun6c2Cay6F9rrrj~HIS|j9$>8lLL)q!6toi>okfL0n8+DSy)mG9>GbTXLfa7Xe#cGCc z_1D#dJ<}cIM(8--KLhd$w%E$m&hIg1woaW{0UYJ#nD=@D_z4+|bsw9}GTiR4mJoevarvo+MbZ5N82i1+>DQkthk z{(35aE4kMm*imBAu*$|;wJ3TM6v3oMtvgZ*v}!NlGid5d!Kx%SpbW57=Sx zjI0LY)o|Uj05+47W6WUoXh3uNIFE}2D6xoEUL#~(2pO*cLoB>GY2UB6QG!FYfm2h` z`Nabc*y@hGrJ_KN{W|sx-i>m#nrpY{8lz4Ze?(7j!pS}1<26kp8I?_s#Zy3T9P^j=0a33=WmelGS_K#-20X&K(bV6N@@}3$FQ`_DPu=3;M1r7 zVBoJDFgPSVe$d^%C^rj1R~>2Zjl4(^UXmM);J%0MqYclSfph)BbaY)l zzz4Q(D*vs-t~5~i)%Y)Qkme!{oT)fT6`hb{`S^$u97t*f2mpj@GtEeIJIu7S)L};A zz|HOJ0)dP?a}SG&g^poJK$8c781(a7Wam0Mq!;gPZ|LJ~0eap3#W}7m2L7*(b)BD| z5+wKrtAUeudKfQ10H!d~x=CfY8<)16f&96LTDca0F|%ko(v@BrN>81)jCO?K^6By2 zpK4;%w0KBqZvorSpg22-ci+E1Ydp|yx53yuX!FHfr8pU;Wi2a4G56F*N%{&T-AKl~ zQ>P483Y^>o*7n&vQx-tXXzF|$2D#IOvzkJ@#Qx;Wm4suj?&G) zb-+za5D-D`zIEJtB86+vaz4ES$oq+ng)dC_4w9ncglQqN!6cd>hoK|^J)1my0JZx} z$pk_`JL4E>R89E@y0lG>XgyO(Wc&*hQx-YyH4>ybpWZ9G@LJ|+I zluUSC##p+3i|$_r=yysvb2F6ko4T286YQLB1)&RZSs`kjXr|vyd<6I|r|5%%6vw0G zWNNv=HsP?m&a%3N6t7nlgzi;dQpx@_LOwZIeN7H0FBBQ|k`AYKwR!et^E5wmymH|~ zfbJitZhkYZz@uG)b1kYL!Y_I5)ty{Zhq4^w{miI# z2_s>WW%9BCxX$LRkY*3&&YkT~Ja=_2aAs2a%`MKhV}OnEuC_3k0GnP;Y0;#V_e^kK z!-kK^X<#*E!YA=*8g~Tcf#V^$tlL64b1|E>o*1XN!3?Ph+uS_-W{})mtb{9rM;gt{T!_!WQi6MHN ze~kjEeD;|5^hX_A*RlduBX7eF4~$(V%x>@!)LWutkOOd~!}lqT4nE6UYOct_)xuH( z{S&~=EU-`$f5XBBX!r~o9jT7mKx4pO&!G&y>f&&)-y5{WwvozV|&3@<;CVRF1c(u{* z=A68erOyW!0@tMSOr0+-aHZ_!vt=+By?0t(kT$Wm2r>2olH9M1A{?#_FgR9IgJpVO z?Vi+7bg@U^zsIz+I!q3Y=>`2VbtrfN8CDI*?)yXTN}fj7S8$bx3u3d0rBUNWh~6mc zF8f#z!lqQVakaH&Yr!{RJVYuP5Iy?U8$K>-rIZz+dWAfA7#)>D>#hU= zcJT{eR*5bv^R`@^$m#wib%zC|eg>+!zMq1`(asYI!BpQJ^+s<6@@>hhoccg`=sx1) zcx_Cy_3aX1a(Hrrm=3weF_ZfI#T8sxfaJ$}*ZEUtmdvNQURjv~&}^s3(j20Lo}|Ki zVril4?xX&||Bg%K{!%WpLGlKaQGI*qdGDJfro3K*PW+D-4(+M>*`g$QFCk&PfcGx> zp_w2lCAI|>$PbT-NcnKjexxkhF1ca=5ao!H*NBoCRL9vB6<$x3^G1k@oL0QsgHZbf zgiofv6c&o6+yf9c4SNlP`*Qu=?S;2vHGsv=Z62F|cTelz3l;z82(Ig;P>DGea8v=k=m*s? z<3_<7s;!YVJb$x1gJs+DJt{NbCzn>kx1Xtmrdq{Ygs}Z>A25N`a zd|$Lfa-3%C9#u#Qu~N$XsqJ;a^6Kvd6LCBJTXSEu!b0xY4Lb&d5Xw>vS|O;3kg#hn zAw-7)$6tPaKmGo@=COwwOSvBZ^6I#DR7uojE02k=0LF*eS#PqgbfrJ}SR+U>m)1Y% zZh`pyFXu?T`sUz+uFsYVOya4ZA9SRXGj2Pk=(I)kjOB zIp<02vd!*Y3v^g#j;0?JD~N8@T2KsdCpu?w@c=7EJeTM7d}!05Zm2*)brQq=L;Us1@E zlyImfZGqS0J3Ifhr^pDOA9XUYaUnJ<|<#{{3@j7#g(3CeKTC)o|#dQ0)%T zbVItx#dQ@ky2KvUR>i(d9$weM#j>3ZdYGbrp+`@@i=uzA7aI&JXP%!1zHgF3RX`um zQz_d$F?s7@!HiEuNY4jqMO1n6TpR~r4O|ZZ=z$c&0i#$#Y58R}TX6Ca-`z7+xlh6y z2mb9`$25HAQ||!FS$iAFVm-?YIWnvgp4hZzWCKE|iR#FCt!%`YW@z(0*C%y95Q=U4 zeC6d_e~(fyw*w%?5sQ$IZ+D=6qYX#cS@{rl!%u|q?FgujVjG8^AgL{Apxr<%&%xRF zAr1Y`23;d0LYu?r*iS;%^cz7TZja0+RPuX!j~;Wgr6NrNwIVz;k0@?Glp6Ss(}Vew zE>=lb$L{}}tjmJ4I+~l#FD~yGHoSGT+n3u1n#MAAO_QHKmh1{%3J%hF86YA4_GI(C zc*k>EX&f3NPh=L=E+D;2%hOzC=4}d%cw)|6EN8YeU8gp5+^<-pFHxTg_qB1V}h(!%%HKIh~j}$IbtMi(CaOAO*;paE>8$1f*0WmlrV|(TyPjs$=V#D5L(`qG~ z3nUXWNA-(Rv;b8>h3-`rK%Unb0>*al=b!1iZ-l^1y%=hlD3*{;YZh+2b#Bi}2}K)( z7wdTYpu8dK9LjmWKfZqduQ=^DO#ba<|w{y#Y_N` z0CP#Z<`Y9BuwF3}?6lw3j?oeisuB2lt;jkz{OA?sl%6UDU39;2eFzl$kj~;IU>n4i z5@^;I9~(6PDbQkXq5g5OMGP?YgC(z+qjrS2pUXU@;BPuT=Am5`+enB4vvQL(1*W&k zg4KGv+wmqQBlE_0Z6$7CwCJhHRi~zHc*K)RPwVjlTb{JM0@|MmlAF^|CuEw{UaKJ! zWm0tWRO@ojuXjy%Ph-o8g)8k-5EcfEKRtEI`1pB(TI(+nm0QBQt**?JP{iAG;zq`g z+8FG=LiZ|Q<E7?>k+C;PhSF{6>N-7`w?jdv^Kk z$9b5APA)Rg260|A_mWtcCKr@0cAx-hizb`W+XG z4vzO4CqnFY&S?vKpH7F*tzqcR`)T!fCVnQ2En-{vYUCQ->S-9 zMkLkJKEZU&f&9x})Hf$~mK5qJx^*}7%LzG&SPG4;$-L5E@-F|>$=_amEFoB~?Yj2D zb!Ey`aHXG-pi_e48h9TzIzZ89h3y#so=NL^X1Im+ForX0|rwn<(p z>$f(qogup|;IGh!MF=n5o}hp3ySjF5?s1?A7j^6~0`7dQRk>ce-Dm6p=kg zcZqVW2Ug@E$M-WY)5|J|fx21~a@>9_Ki*u^Q>UsXohO;??U~!@oSn^5qc(q@97O4E ymx-@7^jXz`UtJ)%;~MGr4d$5u;i=7Y(5;!m=vWQ2eA+K?08;;+Ug<4Ny&U@d_wc|!<5dXm3xSU}cWG58?ZR-ZM-Z#NGq+rer}GEx zn1ZR~cVQ&h;myr3A%I@@=d~OFB)@UpfUrY|qr%ZLQH$GIBF36qx|_S17|qE2ROAe8 zM{b9xeizvm=N28!i`x%PzxS6oN*JcO5qIZR{MAfC{u#Jere=bmKE%u4)$h88UND5F zKZBoTtrAB=Ouh=ascu@(49}49CI>t*U#MG~ZDH9W{ih)9E|I@{m;@rLs*UHj%0@!~ zOLIGlj5oFaxqAR}6ecW6w_%UkEIR#6KGxT{OdLgb{0d3P70=bafw4bmZ6Hn&gQg#^ z^kWhHM&m;cy+0&Sfm!ZYW~Qm;C~y2-J-u|dYoO})ugsPUN5Ey5jAda;koNkx*8X{# z-P5aw5=%_J#UXEOn+o@CdW6ufzn`f=-Yr`x3$*Vt+MN%zKcdx4!`dGljn4YH9%I`k z{jpIH%lwOF1`p5R=R#<=vN+1T2$af{xaBRKM}()W^U_oB_m+01$A5 z^gvL{GCutC^ptcGaP01`=xe`>l?TXmRX;S4h`MiXpkcZ&vy;yu4%!PgGPTxkT>2LW$CL~S;ASzNFg(fq3x?7 zNbB0>GzPlEq=WNVT+$I(2d`%V}Y=3eb&|Csm=jsGH|u5bSAFwlMl_k-}co{eD4L3 z(GeON=`h)S$CQt)FWmCKv{h5Ru?=$JPsrhrZyWuqC?q$hb*D1E(k+*a9DS3 z^yON(0%t^wqmtaKFo#si#1R^$e$sM6iz3Y^VMtw_Tk+NU4x1Tfl(&F13I?fr<;LV7 zI~NUU!Rrs)LscMk>G_2LC9Q($F}Z0WwZn^A%hFHq8cUZN_w%v2;STdjlNw{~?f&Pj zJTaP=o7IiPW;20o2UZmU>*f<<|Cd_A`W~3gDd=hCh~^(5dHz$|W3^0`t-jvxub7?y zMAft8uDiNw3aeNSbGt0w#i(p9=Q#t#h*ujV{I`SP75`Ll~;ZlOHc2O^%PR8pdkn7J(D$ak%lRb zJe^C|qy3-f-mFCpSpu}L79ZV*(;uyj@Y4>^)jnX>$PR>z-A6bNrsLH*Qj;HaEsTIw ziIl(lZFQT&;Z$CHUsSTMnGdq{uT*nKVUbm{V0>@y-}SF5g*@IC{OowF6kb}HU#n`2 z_}oqtN>_wzk$gP&YhON~iq)fczrzGt0;4m7g`JL(@`SfW4F2u~TVV9p724D5yzDq1 z!EW91!+dNv>`e7n>g@Qq$(TU(aJp{yvrmouY?sm`8P&_A&0()>luXAXes-~$XBS*z z;ki#1rY27Z3DE}yEKr+0*1;BIB7(kB^|PdViGl0J+2I`;XbIf1R}P(7=FGWb?QX$)Qd8*?WKY#7xL@dnm8qs z`b>}C>yiE$kECR9Gs)s~&XSJ7^*=#H6|Rjx%vjV;&Lb_4{+jEyv#u=v8_5EFdB|cB z>WN$NaxSdY{#2L)y;-Nidn36s93r6H_NObyh@s_o{+g%ILArMeKARK0vvq4(rsUhg zxJ2a{VZB4-@T2K{^3c%!St){(kdxHF7*MRS_0X*BiytdLV%0ryMb0Qs>xG!9dx}xX z=r>6jd?1sMlaq5g{VgjD5Uvc!XpqcyH|f@FEuHCY^c~KIg)W=-dbssJAIon#5o_*N z=57)!MbAsU+Xv znHSqAd)eV>=k>zaf!uoT1(c%L_lAO=FXxdIdj|*87Kh{=cJtTY$|Q5ORfDV|>XQbF z3SKF8pl+KBoYZNfN%bk-!C`Uas6>0*>~T_$>5HPr4HQoYvDF(%RV2FZZ+Vh=^^t#r zZvuL$JFoQeV&C?g0MSuVW*mLiygA}p zbe#o>G|b7{B*x#)&Z>gMpMK8RKRkdMlB?@gXe`r|rrTah$)CU21w;CvV>8WU+O7OF zNSXOMIni6Lf==;8#4(E5ysCcAxE8_rfh|uUtBTRy{jdR?nj%}!qtq4sudjCjUrFN% z^!@y15Voa84JFbgO6YSj}Lli!)r zniADm;AfcZ_r^RJAJWSj;>sihu|H284A_B*Kfz1LApCBlimA_l(wWe<01P=foX7u7 z(WT|&5wtCqCEc#U`YNQ%TF>HnXX8aj zNjjtPZC-pWRR8AtQtHqr47CME@5I)E2VN6YGO)9?RkwATsLmZ4UsRO)tjbIKjuyrgDA-KS zt&|{XS*A!>1mI#roDE4mT^mz3m};n;+!MLibAMZ1;}PHtO*Z6F(d+Q`A|y!g7vK%- zL8qm5B|qmP9}qMoj%MDUcuR(}m%pi)+d-`@Q??%{bEWq^_CEAF!2TfE3-M&m>K`=k ze7Q5A)$t`sz}f&#KP-8_rqoALlu`HTG-QY{yuMYwH1JcM0>zMtThH$s=J*Ud*EK_* zrB!KyAwxMjhVUivND*(Y)K~Wd0$hPCO$Lu%Ru?_|6_Xx>Weyeha6)LUkPi(vzwtQF+#CN zEBq7Q^ZVG#-FtJx?B(s53ApU#QXsjz+Cud`Pk(OiiGz(cKTf{vU#(BoBGq5yQh!j* zMfr26|6K)NmEwcrHaUsJQFA8`&;(fz64+IKVvYJYH-Kc1>rZI(opYvvgTeb~8QPLP z;{zZ;)zp+CB^lcL5#syzkT&1%Z*^W@gJx?wr_}%;rp; zTfQjVJYj=)I5pzNdmdVw#zP7AnnPm%^C=&2{2l$Sw_HolTTh>^UkxeR^(1Ln3xB>c zZP`!uhQ8lk|D4uv06OcSJH}ML^Pw3HiFDk7@*)JoJqt{3$MZwAZ|Z)b+5oWli)1_* zKkBwZ3@|!C%Q!|-qm_W+P}&Z??EUG7Q`vu;Iq|AJAC4WE`?AcBHHkZNzfO`q)!LP| zgaUXU;W6Q;K9JF4bqDE;fmFxn7=_#B^1@+CB$nm~@zoZ$kV+f%=LMmNJ0GUkfw|2F zzvz-3P+zgucKMgNZhv3eb8!5o!^SjkAllTvKjmXqwlMqW#MxhJXY|FV?@eSJhk*y? z{hO^t$p3;Cv%NLSgXW#uEu1(r(Na=JEr2~&{NI0t?-o?YTYrL*(ik(sJ zS~?NyVs5h3GY*j+CEoaZ2zO3`nSHuVmWW$mbtAZ9=9f!fTN99aR}QWp?n6>>lQ z-b8mS#CJ!6?SBi%yf@JV`>!GY{|)Kk9WW1A_asS~dAxKf|F*Ss2GC0SbBKMaH4b;G2j6MaP z2|19tgOw$FpPT>|k!v9#$f*0``KIt8F*-JACypCOGC#O@ngUED19n}bU${_oh1}aO zJB&r=K!pff4KjG?evB^?NyXAV^Y()T1POB0;iD-9Gnuz(K*~!F`=v$~%`J~$^?iby zuUcoANj)^?(>x5(mzUJXw$s%hzOV!I%f4nmy44`k0cVukjbQpplE!*Wm>lc6SYUQB z36gzK@TD?4wpNVJu+xxw2wZ){S;wo8J1lrZR4#0W?qJph79NMHKR&WX_va3r;j$uG ze)7C{g~yPR37Oq%nZgPUOB8A|3hiESrMp(ZXD>|dwJdkbm$}o=l=2P^35lElX!8je&w5JIGBrvX=fi z^}7rmm=_f+A_hpT-Tw^J&e(4cf!Um>k$OMVZY;<6t~4n}vjku^#|6Ei}=l4l$RoeB!#8lF?{cqK?%Y^>K+^` ziA{7o{9{>+4g@?-epl~3sqjGqa)byOu>#}<)WjC*gkgb5kC!R4j-qwo;A*QljMzfV zImp)dd|@)eOoEQNk(mlQ+b|1F>)OUIDcZGiUkCxfJSq5X0?^oyX?dO{1H{yBuxgn|ZE z!9n0CDU-QV&+hOzAAep|&)UI?^Dw&a9)Ft6j5#YMq*k#s8kBGzB8b4)%sKv)9s1xNeF zVyNMh6Qt-ZgGGS_N9<6HB1!n@KHQ~mz>;Mg1`>I=I{$@+F|6*XfrWW+F~2nc{#Bh+ z*nABoH~#0$(6tLcri9AD{;1u^z{Q*p`-5q2)6LM(R4-Tjt!-^(Byn1g{GO zzF()XS$7&-c^tuGXOar-q}uTIox&1-CxpKgYXnlgSepCT+;VZ;SV8t=Qpr3>6TKgI z>DRpbup2}GN8&%%T!@%#uBnDr!Qm+WYLV0L9U$f{x4z&=hfG82Cc^v$*C_t=VV~OQ zP;s_~>e(Nx4q#`!)eE$yPi{B_-CG_5`AX#KYY9O<~J$r~9u@2WMxSwvRs6K7in1GlH%?dv~{qW9#=U*?Vsq;Un3 z@eI8yJ?QyJSrRdUr)#oPN@#MG{~0g_BvE-|Pnv7ANQt?1TiyORFvv3d z&owK>69RyO@;#y4D*b$Rp4kgaf4YW{WZO^Ve3*ynr`1t9BSI(ORJWNHjf5 zcd2V=c_H#XxF;ge;N(+_fTn=pLT2eqyU}e{GvqlQgZaGqC_h4$WMEOXOPU6%x6w%yG6NSx+sdLeqOwZ6OYLge1=O;?7OlwU4QCiMeaeQcSD6_5Dm((wXIROPH z`4ezB-rScq>uo(F{b)Iv*1oE@%}O!0*1~I-cK%do@qpXN0k0_DpW-E=m-U3EKVQ%K z-1euXpnN_;0`7Ym%Os9QM?V9d=VsJISb$NrV*T;8Kca97zxOt7X1`i^1Yq7$u5>5n zWCBm=az8e-SNeEbwp~G+T4w%(j5SCf^0$0aoJMGqG-Ni82iN1O_eHOLSt0>p4PHL| zETL5|Y^!83+xWf98yR2FlaeyK?r9`X-}jdoog7hS<&u*AF|z!GslA!Yq8=3!3UIb9qbIe;ByzmcyXUUzNI ztlc0YRe(U>enI3B$w(^l_7pUrG7v}!Ij4n)jlm?z*1lX#sJ;>v zte6Gf({0bemb_V-y1{ux$^~|R{M&c~`Cb_A+{GZ$#%pV9 z@3*M)5w(v=61}a%KN*2s0U$cjNu6C@dRp$CQRSBwF3aZYCYpCfdT9Favqg-bA#0v@ zd5SXVnXlCSOzjWiKlCmIqE+^>A6CfZo9Du86sI*TweWNo5F#-TQ5=wf+o&3>v@|Gq zRFwik@&`@r6%!ki-*6r4Mcbm+eMk2%xnP0#cUVi1mfual)-3z%(XIpWKUZwcFGEws zb*+)xR2~&UTw)_#?^T2f*n0Am2Ndc=LXXl_c&U#BT))tJdNL>`rKh!9%^DK`#EX2T zDo4+?7W)Wd=;|5y4}>CB+qz5L5EauP56|t6gXF~S&&B#(C%363mArxCdRfi<82Wbi zS(9-wA4&{h=XP1+24qz)r)%n&6r)$NJwQsXG518#bjw9sR@G_oweRbZ$&zJ7MZKY) zBte|ZgwKJHAW6;0_m*@*K%Kt-)w#(+wo!|049oXr^0QIFjzjCNJ_$T#8n&01MxS|) zUwxp=SQ`Z{P2V8*>?zstMt!gvgsVaYJzDUKbZ z#APPu=0EIFGGrheuN6b2Kief#>4+znl#bUTzEY9aeqANKaWL(>fx4eEW8>kDyv;A+ zGYwN@H;ygUgC)d9Rq4F<&Y0{a;6|r)4G+-CZhZku5dGQTsf~fo-Wx*1jKOTohYsGr zwZZf!BUetuy03QUEbDjqt>Z;kMCh>$c(5~i))|}AvuxYNPa+JS2ZRZM|6F(tk0aj9 z(AAv|vv8|ab9t8o2sznhrZ!TH2t>m@pfjc6PICqb&^G_o?GH!InqgZi%a-wpD+v17 z*CP+Jk^S+EoL?2(TzvsKasv#_FEyoE3`l0MVGGu&xhWOw8N692-J@Ip&4j=F$HS_X-wkgtxAumiEBfyFyP4rn10t7bviZ>|O`cZ@~3sdvqqJvZCbyb;JJZ#A^EE^X{a z_7}eQ3v?mfN#)4b^GCX10sp+%x5kTL8#s$&t(Vv!+U-lo>i5@#6>h`n>8CsoRn3qX3;WM>&hF@Fd0@+(8x`AQ&@6Ecq zs~=Y8T)_kx3i131#@J0a_8o8Kyg_b935H8Y1h zghKa;D^BXJcIiM!rveQ{ei>aqeM{Uc`;ij*)6*48Q&IPNYG^|`XM zM5GCnbv@I6S@*7ppShJ-k5h@SIVVZtVelG*&50eh{ zpb1zlt!w?S(CU*lrQR7ni3*Jak_(#9P)n`OC8;Oi+4Wj{LMAHdYQdW(7k>7yZOP_i zub=h(=I0y^cSk@&PsV9ZRr!_KT5-Y*4}MfP5|23jq>ecVlq04349}^xp8?9+eh*uX zx#`(p4i<^ZqI}J0jN2l!YIg?s*%}*!TdL_te;U$M1G(PvP(&bgS64q$ez}*f6JBcF zzv}!1xLO;bEWXqoIkVZ|J)Qqy&<2Hyg(iy9GZ~s4l`h|CO7~M{vma}bYJ|+|;ZQ%U#1L22*L|+K2wARy6A@XURW0<=O*MdLzf<&_eb$@heGd?49@Mq9t$eUyn*s~6ri7+riw59h;O8%=sI{Pu#`&aXw98OMIg}{5^)pDIU;tmjhT_ew;U8#F<*94X0$jxyD@{S#7UDcs>LktU72_*CjTS)8DQzv z%eU1t8_dLUx1_kmUuoX2upXXIz`Er-+SjFB4m;(;38A5hc7^QA2Tl2QIaaKym-EM; zmDqJWOe?G9bA2GNWe2Dy$^X-Eol~q)IJ1c|d7w`#s}OV>_OMfe$SRP+649v*Oh=Q+ zccU5`5(OFG6w(v diff --git a/tests/references/SpinnerWidget/SpinnerWidget_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_linux.png index 2b75d66a8c62c33426eb1dab52a41e56486d9d5f..462241215f9c6e6d6ff92da7046db012c8c9bea5 100644 GIT binary patch literal 10387 zcmXw9c|4Tg_rJ4XFtRh&vXo?-v1M08hzi-mFeF<<%5JnsOd(6i5{2wTwyYzJLPOd2 z>7yisvX!mh>^%g4D|z zl|AH(l4Jk&D^&r&JKkR62jI+;y%7ZvEMG8maA!Au>w=+1)aC(e(xh|4zju-Z(bloZ z{nbi1g%5@IkuBgM@DM)BxlE>{rbM>kKd=8wO^D@8+G^`em%L;66gOh8CrAlE;mcmJ z*%K^CztT!^^86JFEt2b6vt6;alhm5p5sX+mtnV(Mid#Idm!_ucp{@?NCJB+!rhq3AraHEWMeVqFJJz>uBA;(o*GxLpJqk)qmfu09=%xH zD>qkE{EB3}k%PfzL(krRP|>2vUpd;-htB^ZwU0|=D?}<#Ip#WgRb8{WaU&YPF=rsz zHU%lJXdIQpwP_*yTh!Ke;wInA`ctpnR(2bEf36(x+H{Z2|5~-V;k-A0&p=W7J{3bS zl@O=K29DT!mlN6qi8w<{XEZa8n&OHQi}7PE{AztG{E`EwclmUfRA^H{!W^6_bdCto&^eH5FUo$`^f?xz4aB>ddIIC9tN`REvsIm z-C~`0yqhN6s{(T*885~Fn$hznvCh%mThjdY?>q{8C2{_W$hE%_iA^IjTe{5C1zi}w zBH(v$%gB|!fT9nEp1$K3@3@pzq!})h=cyM?uruXCJTwHkmm55DB8zH@8hLh?`^a@{k=UHkUbjnwkBf2Y(1PXcgLpV+xTNPVH7MJ4|y&Vlat_5Ju)q&Rkl7sPTXxy zDLTLF`pS{R(wJ$P8y0RPcE8XwN9XQ`Y3?xXN992?VHabgTf~U$h1#51k@IyIj;^u> zoMw_PC@j`rcT2O|^&|~4;dP+}pz?}KcYf&Ct<6-Ae6T?0Mb;^Z8^%>1i;31gM%=D& zoK+4YP;K70cJk-^wn^X3`qY@QQA6aX3{ok#`Gs=j)%x&S|5svoaL! zKFbQ6;lJVudk#rBNwpo_+;Ga@F2k=)`7#w`csT{o!Y)-QlU3ot&K4#?j%MYdc3EuMV9or#8&tJMmy@pX?<{rh`Q0BF3R1ewRuF6J{m2C`f?V6msR;cs z*V&x2l@v&Ul@Y7?9;ChZ-E_k>xA1g9eEtgokw09p<}!?)mDRoOmaI~jef<<6jMTNyo{H?;<|CydR5)+?K z_hAU;glQ#U2+-^@i21)?!cP>w{w$Afjs*vNm&Dz?UC(FsXHzVZYc{T=Agit6HwNE?KWxOzK?`>m+?H@QbJ>~OesE7m0x-k#t9EfuL@1{N{OSOvqeWPe) z?0pb9xez0ye>|z>k!o|btS1tPa#H*-&kfEz<6OI5cIi=-9Yq5E$4cye==BmMsn#>I zwuBsvbkEb9%0w}uv$C~c*CPe2%W`u}k1U0Qa_RA~!=t3%0WM!yO`nfxm$jk)-4~D;OSj>V~J_xdk)wXXJ%o=rhGI$=8-C@qsZ-oi# zSz6howvIHiB2GjP+j%QIix<3^Y})AbZ!_T`G4Q>zC!|k_NyI1h?kqOEjCjD?t2@3% zR#qx(wfSm!!ia{FihY4(19PkgV(hA&(~i4ckfY6TQ1U23R;m&l$OVrmo#?IpYJCz~Ha>&^lM@xMdoD;Z<1Ee_3r;BZs=!YNWonDYDQH?t92={a)`_ z(8ND1t9_PxbW-l|6Wm7WY2v*eaiXQkznzb*t`}HxqD9`+?LVto=TxVzCGfdqz56@o zvBs$EJ>B2W&d^Vx)fRke)RyjA+B9LDTev7n`T3$Waz~NCrIuJNYn5kcl4NeD)~Lkf zPpZ;g=uyA)M!{cJTrsho)bp-ATa`#44O_7{2GBMg(2$wTc}mLszx{Kv#i5|ZA@QsY)iwH?V!vPef>}d8I znETfs^A|M8^W(g+onQRnT~*d8qMu5C%Z7WOq`%?}zZ>?+iWV$y`zh zDD07MTCl0yx-TtK5z*XBxlHv+8g-%C6bBjy<=&f$=IyLqc+u(f_P&x%u`DrS;M6@Z7LGkWA%daz%%d=k)4N=`mfn^cV%{&isl6eWQmqJTN5zYUN!LC~EM9$u( zDGjO;%WB@78qQ{eaMl%q-CCl+rj&zF+1Va9>_C8ON_(ECEfb;Wi6BKx*(=JpGoTlq z_@=#yb*a&M*Nt*fSdplYva-^S?22o-ny$=DNpbfqSa&Nei94G*mN&=XZ&JKcSZQ2H zpW-Lts2EP^o@&jf-B?Lh&0Az3Dy(YfBtIg${0~L^X6vn>tDE5LsbD64uS({8PkD-k zm6b-sg=shnjM!T}%`nYZ$20}J0u@X4Y*Q!W^i=-A&h)BRJ~S835azT3ALqmte+AkO2eFqTmH?;2=m=D}S(&a`b z@)uG_uXr+e9{c{(_^>mW)3Qyo+(OfRX<@1DV#vZPuKK%dbdFmcU>gnp6SMu`)ccZ> zxVy4G?nRJmPkTx?!`D=TFg~Q>3|jQ#Wo5{V-bHIAA}cIlmNTh3%vX@XZmdTX(NH17 zQVC>VIVbWG@ES}b^-Wh;#a?F!(bVV7;OXX3CdUWW$*mC#{{I$iIu(DhP?qCdA-;$0 zwu7zkh6-<$z_|di2$<}D0dKL{2%!mca2nbcfA_F*LsAXS#aSF>926i8FCT}3tfj)a zNMj4&ooFFJj$C&b2`XXC^rc!Q?u#UBAcDdYr+0_3w40Nh=!o=hTQyQygi-W%2#gkS zw~o6oOhQ3rzA5r)lNE19Qy&&Ewc)ZR-=wi^iG(1uOIFzi7PB$fA(59tuZ=eArs!K2h8%;Vq!fjK4q& z+2nCmW=eZw2dOdLcGIPl9|7bxkC{lTnrUx+q@1o}rej{(A6P&_*w5WMZsIt4h2X+0 z0KtUvTK>~wuKkrYRK=+ttTH{5X3%Ifveqrabv`7QId!-Pmq=O zq{xn-@U?%Jv+mH!*ZLWVc4|eHae2p?XC1VN`qxDj92e^A5tM^X1y59-!0t6VZ#29FR`&+#un_S>5jdO{)0m$o3U?gz>$)ZCiYLUFBuSO1w+c&a)MSL*+F-{ zsO!{~SU*_|#cVLd+rG3E1L(F{>m#?ljO>tegbNI7l;6)LWI%V(#k(23(FtfU61AqAp5Tp{PyQo=`CoMQtG91z-Er0P)U8VannRLs}c4dA%0S z{4o&Rk@W*ZASHcMAYR?5koK`f&IJWBlD%RAX>axz{9Q-vRp<#h_~sjR#O3XS5(F1e z%RMm0ej5n>nro{{iHX`R7;x>yJQm-QHTDAw9cn$Jn`5_mYEHTBf< zTv_0ay-65dD=Sc(z25>Sme?MABL`HJ($X%NllK@BIX4GNWmcG}fl5e!l$m0bp9=0> zOB<;EFYDPuUy7SW{!!-sJ`PH)#=biwUw847jZzAtKU|0+c0Wbq=!We9{C#5RMi=Ia z$B7pZ)oUYj+vyy&RRGXT&h2a?{We|wQO*qHOl@iL8Kp3;GlfG4tYJ(5zQ+buX7bZO zw$~Yqb8*H5(1UWB{7s}|Xg?G>P6M}#mkJ?^5Qe_Pgb5_}Z#J&?zX$=v?^%}nMpFs* zak)(7*3jq`39z7zE`C5wXNM5*s^Xwv7X?gLT`+nb^2&@67|NoYd-=;f7Jx3W4IY08 zE34Q&d9n>obY4t)y=WCZA_Z-kmpgLhzS)4a-rEZ$?TiWH;B4Iw-u#e%!tXz|c44l?!Yaf)$9n zKid{1M8S&!(S)gw;9Cp0_D=vf4f&z2@e3x*u;I6IVRWlyJ;FO7Sl@vXXnx>$-85K& zI6VI-Fb9&9PLWqW(aZ@UxaA-gdJi_#J~e!gM?FjhN_szjZOzVfYk)Jkryyrnvpyav zg_YHrozrPE!hqjm;)~HAT~08&o#*1jOi!^`o^dl~-~-JDaQ?It z^)H&fbDO?E5*3P>JmY-Me)m?k%s9gg-dJ{1l1iDCR5Obg-Smu{LHfPWtU@EXTZJd! z=`tu7KC)3LU8C&kW2zS&U;y7S^n)1OwQ5vppzK*@WzhFdT}qtYnT}So?113oVWI{h zIDNQ7?McdY!=6F^>Jk>&BSaY9a51l>P*jY&<3mZwe{T^OaP3v2hQu_*PS6g<6q*R| z(u>xN8cDyE6U+8Fg{MPdfMRp*+~c{iwJy9&2W;xE_77I@W=Y4sxT9wP4@xzO%y^KV z5n5yMFf}h4SSyh+FgA!`*oBiaE`X-f_>|2k0IRuqy?{eQ{~l~gJr2)+hP0hy7{5zP zJYr)N?cQYpX9E|4W$&vl>wPccr%c}gA{0R8SUKL8MH@AOv@6ro#{XcXnJEy182BJB z$6w}EeWX~{w*cXTo^l33(F=VrP)1z;+;3wIjbz*;LbILrLcDt3Y8uT*?0WrZpiq|? zQ>6M9rndv9{ORzYK{eYkV5b>wmEkHnoqXY8wIaNyq!{ws4QH3a-0ml+rc~ zcz>jvGQPt~e-8yx*`kf(2mn0kp0P*qK85;6qD*!%3zzFb&Hdvnk7AzzBXHEAQ zvAX_XEVeZv`r?2V$@lE~920SP?fpki56U6G4jzp0Pb4a~vtWQKHB;&m5(|J3o{JAG zv$B6Rh2jU2R}bcqaB%xBQ@{yZ7y^)C@I&T2znqx*7a^ws#d_>64GB2`sABmj;Ro>l zP4H=7A#JCH2Ag1wV$pv5+(i*9%B6a-Q2?M--mke-)55tTo3& z)ewb8Sb=rX(Ymq+)wo9ZMO?*!D>$Y*4eHW-xiz zE_WT+=CnSFmRscfdD12HM3zrOTUG05z4+IhJkw6Gl#L3w(Mor@b2@R<#N zI}e68z}@ejW!ddl|H0_L{f5730FY&axMf-CS+Bu0J|<9hK6}GFIyIHX?y$d1kc3je z*+tw125ak~BDup@$(cEJ-I^khVGmPcV+CnlW@v%uS_sug23Q~~S#XZqpSCk8vaE-o z2#!V3vJOHtzl%ma=PHLrqM#^4YMkEr?kjf~EcX`Ay6&oQ%`yOgTem}w`Adad1WAbHuX#(W7hNN-1^#V4cV1*aN;;a5F28*|E~ZV zZ}rds!hB!o@qrY)y9btNC2|<`N^^pY=)Lz3^dJk{t_m{3)im!xnp1>6e1Sjncir@;?GIp3}U*HqSFHTpRdbT>Ro$J_pnS z`!5}9UR}|9gJL9Z8yKE>x%5}_%&J}>WWv>#Fy@|hu+sl(+36Jn2o^8cXn+^OnCH7d z-4hF0@Fy$OC(a$I?1aJ(YL>nbfcDjV(dQ*~j3J@YL{CFF(Qklwzx5fk?@AP;=pBA{ z;?i5U)sVP)7DecbhxvpGoiOVV#{j2%(e!2Amz!Qd^ZU`L-f0S0i%YTiEJBG~ex8(S zgJ@`D{b-B@TF}4TKY@EQn67sasG8&VJxC$Yai#9jDH*5`vHwwTEBEHMA&5uqLmP5q z@8fgULb@rp2Qe>}e+ zXuLZUb?tz$h8GIVzY$@}LyxD)-;n^b`p!+!rZTgMi=@clYydZa$NL=6zN%p)K0N3`PdpD1T$LV0-~`*nk# zpu>*$ErvjXD(ZLqb=-i5iDX)|Vf>`59%Y<@B|`)VX@ z5yx=MA;tNN9CQe--Kye|Dd@Z+3&t(j&)jgP)cCPWHQNxRh4|XR?^j#7!*iUMTiNj6 zg&`{rI3HO2BY7&Nd=I&#%SD_jKJVQAoB!}EPW$Vm%O!w5CLyL=vTL-U^G<2K*&wdk z@`nusq(tt`0hyk=zh$&kE*-j5XD=f~TwfiyiQiAO*xQZxESE`nF|KMnv~Ps|1`?Sa z_pT~LR6P_AZm-Wfz~CQ9vaKb zuzm1fU~k>yFz496VRud>FE-k5nsc?Bi+k^jv;`@E2O@j3Gjiet(D1l{5 z6JuW8(uYE86G~OUBaXj~VFxL-#IpYIJ|~2zP({p}>9I)0icXS&Vcz#0=FK zb47cE$X>=tnHm7}fzZ(-0juz9pSOJ<3X>Ht@yYWPWITr$E9bcDrG2XR z={JS>YA=!KAlk%56>BTpGX&*g@q~2=n7I9ag=^kP3 zO@y~zm6wgeHTlN8Mb|KlstA1os?RJe!UCq$V;}gantj~;@7Ghtj&Kv8InZ`1n(8d; z)_b$iOG3j?dqo(G@aT0Eu-bl1gP^k68klS2SjEYP7s46V{_TV4L)2BdRo2~L2lA&P z1wcNsxyC7RwUKDxD@+l$xD0W6?^g*iJZ`fc+iH?Q2z`PUWowQ)JM0v)Dq<;BMgEBx z!&veZl*Cq4*vQKu$#+vM%2ji_s{fLBm&J*XanE|c{i^>Uc?}wN*3l+`|48P{9Ln~+ z6TCq;Q{p}cVQg^|yg!RFLuG$QE9F*Ywjjx#GWo-#1@A*kJ$RL}XyUB4kacu767QtR zf**|BQjW^iPW~kE35J&+UD~|4ZbuTeWS8}FhG~tZUa@k+5;KN_3}!j&YAc~*ncDX& zHWnz-Yb3UaBeU>_meqphyPezolxweL9?|e1@DZ-0!r5CS;=JvwC}OOp-cLuAaa(W` z&=JzdRjv_VI{)voP8a>exZjwGc*iPrW#`)hzr(mE5F%s+w$SJ?KZJqKh;nVF{N=Rc z=iA15A|Zp!MdTe!U(Y=zCv)uvUg`-ag_@p!((3IwRG#M|bY(_ttQc}XPYXHxc!c}#Kx zCg9%A`ib)4kB=!aPk1Qe`7G5WoK=DD7x)=cqs1$^X4iSftvMr@4~a9Ww?BbKUrMD) zf;%`WRAESP=9GZ$BnTn#7Nn z1F`jd@VCX@*{wBuuK$i4rT7N=E8If}pxyFyS4=uT|Bm0+3xQ8%o=iJ9e+dB3Xy zdWv!r`ttGp3ll|*T5e;-V&nR1EyiVv_sO>_FrP>!t1YGUbv#{}@pg`}v+%$;e5~f) zDFyz)pUA=!tZEMz{8mHm@Jo~+Eli41h`I&;|Pp{B5)b7V<3K0ncS<0SW)mvS8*?TQvrSU6U zOo1KXY*W4~zlcIyLH{#^Icxl2MUD@`;;A?2!JYQ6BoHzjju0Pb~p5lkZO94gI{@C&tNYk4f-`mea=5 zM}0c43|omPz{admA2)X@9nOr;toDkWS4$wl$M)RGmd=$WaW_*b7l(p|6_Rh!l3LX= zn&AyrY0nPKrvJZCM6b~G>VPtbfnD`F>CFcm>2W0?ei|CeKRfDJ56d!Wh4~m7x)<=2E~#v&wnNAN zn9Gip(m^_Z6u$}-J==ZWB$8S%k^I!y%FK?B98*|aIUMw2I`*1#mg5S{hdPmb!`;rb z44L*>bZ2YzeBLxFBk|usx5nxRV;HtD`R~?J7JdN5b|x#|j1dkI;Duxvu5k0vNtN0g)f@ zweZReT}H(~r>HvrR(wKj71tk7oCWA zI4DZ-xRD@{xwR*^%z1<#eQD4`nGKR!T8g!iGkT!kFaKxG4?Kbp}+}d3t-I|w-Ya$yu|etDqC|9d@`?< zwY|qWAZ*dF=7L8dk#gUgw{tL5t{g*!RBK8;ivCmkv5RT}uJQ+)hyDh<6l!_MmrH(v zd*W*nKbF6i)$v70OgU(plnRTBtxXg6IBZ^MHN7JvKgV=G|3;5gzGnG(R!w`3=OUEzhK1h2(1==*5i^fF97-7wK z>UmA4(|^^d5D2lpWX9nz0qB68l2U$obMt@G%=&Xo%hD26RF z!NMh_3wY*a*zLY&2WBn0i%~Ip>uw)*cfXjHxev20BcYa(O5xRvN#?4#!VOCk=`N^4 zr+G{D#bq!i0{=sC()g)FP3)BV9lBB{BqqE8Z!!Q!olK-5F|jxj+pmT|TG;Bjv4>je zqk*H4yYJBKZaf*d#={^=xZae>HgZef2aK~LmfRW&pYS0KinvPndIq-qwczHceOp3VxUC>+IwH| zC~m5J#}94^sBI}L_BiP~7r|6IhQl@XlFZoZO83@RdH*9w|0g;|t^`v0R_9op?GvF{ zUOgj-$1+^v7Ta6aEPhy4ZCGG$Q!$>zSw*w+mZl4i9E?*j`msMb-i-d6*?M!Ne3J5( zlY%Y$_%IC9_D1%`2RX&giSW_xpMs~MJ}X7(mfdKomT*fv3KAy@L;tL2ns4DYDPa2= zCYdX1Zn3l9$u+q?ReA7Y!typ>)0rD6yy@GAH{*VE&7|Xgp0bPN?>Ql`bc91)x&U*R zGq87JD-!SlapJGMS&9CwwpM##-Jt$C8oxoG-U_;onpd#VLs4IG=#hThQ>^g(@ak{c)CJFHzto@Q!Gc*~q>!4V zz_w{I>R1J7^5@jJL`Y!0pwipQTNxH`x;7Ha=TfdRq9c!3P3`zm7wFq6E$Z~Kyv@Y4 z>3Mi-+zsF$Myz@|JM~$nOEsY@ee2j?YhL$P<$o1_9<1vrd@$PWDXXQv3ahrS`hZy( zWFVh}V}!VqZtqTHehM6%y>cK^{`4WfHL7aAOIzh%q|4Vz@g=A0W)1K$9vBI6OaYd~ fo+<&|EYkn!eJOcxv5D{vK##G(#d8&U_`ClH%ZJUB literal 9490 zcmXw9c|6m9{QqooW{#5OmZZXBW$vrUeU!?vSf-jIXWz^{CPnc@rACCJ$PsIy&U@d_wc|!<5dXm3xSU}cWG58?ZR-ZM-Z#NGq+rer}GEx zn1ZR~cVQ&h;myr3A%I@@=d~OFB)@UpfUrY|qr%ZLQH$GIBF36qx|_S17|qE2ROAe8 zM{b9xeizvm=N28!i`x%PzxS6oN*JcO5qIZR{MAfC{u#Jere=bmKE%u4)$h88UND5F zKZBoTtrAB=Ouh=ascu@(49}49CI>t*U#MG~ZDH9W{ih)9E|I@{m;@rLs*UHj%0@!~ zOLIGlj5oFaxqAR}6ecW6w_%UkEIR#6KGxT{OdLgb{0d3P70=bafw4bmZ6Hn&gQg#^ z^kWhHM&m;cy+0&Sfm!ZYW~Qm;C~y2-J-u|dYoO})ugsPUN5Ey5jAda;koNkx*8X{# z-P5aw5=%_J#UXEOn+o@CdW6ufzn`f=-Yr`x3$*Vt+MN%zKcdx4!`dGljn4YH9%I`k z{jpIH%lwOF1`p5R=R#<=vN+1T2$af{xaBRKM}()W^U_oB_m+01$A5 z^gvL{GCutC^ptcGaP01`=xe`>l?TXmRX;S4h`MiXpkcZ&vy;yu4%!PgGPTxkT>2LW$CL~S;ASzNFg(fq3x?7 zNbB0>GzPlEq=WNVT+$I(2d`%V}Y=3eb&|Csm=jsGH|u5bSAFwlMl_k-}co{eD4L3 z(GeON=`h)S$CQt)FWmCKv{h5Ru?=$JPsrhrZyWuqC?q$hb*D1E(k+*a9DS3 z^yON(0%t^wqmtaKFo#si#1R^$e$sM6iz3Y^VMtw_Tk+NU4x1Tfl(&F13I?fr<;LV7 zI~NUU!Rrs)LscMk>G_2LC9Q($F}Z0WwZn^A%hFHq8cUZN_w%v2;STdjlNw{~?f&Pj zJTaP=o7IiPW;20o2UZmU>*f<<|Cd_A`W~3gDd=hCh~^(5dHz$|W3^0`t-jvxub7?y zMAft8uDiNw3aeNSbGt0w#i(p9=Q#t#h*ujV{I`SP75`Ll~;ZlOHc2O^%PR8pdkn7J(D$ak%lRb zJe^C|qy3-f-mFCpSpu}L79ZV*(;uyj@Y4>^)jnX>$PR>z-A6bNrsLH*Qj;HaEsTIw ziIl(lZFQT&;Z$CHUsSTMnGdq{uT*nKVUbm{V0>@y-}SF5g*@IC{OowF6kb}HU#n`2 z_}oqtN>_wzk$gP&YhON~iq)fczrzGt0;4m7g`JL(@`SfW4F2u~TVV9p724D5yzDq1 z!EW91!+dNv>`e7n>g@Qq$(TU(aJp{yvrmouY?sm`8P&_A&0()>luXAXes-~$XBS*z z;ki#1rY27Z3DE}yEKr+0*1;BIB7(kB^|PdViGl0J+2I`;XbIf1R}P(7=FGWb?QX$)Qd8*?WKY#7xL@dnm8qs z`b>}C>yiE$kECR9Gs)s~&XSJ7^*=#H6|Rjx%vjV;&Lb_4{+jEyv#u=v8_5EFdB|cB z>WN$NaxSdY{#2L)y;-Nidn36s93r6H_NObyh@s_o{+g%ILArMeKARK0vvq4(rsUhg zxJ2a{VZB4-@T2K{^3c%!St){(kdxHF7*MRS_0X*BiytdLV%0ryMb0Qs>xG!9dx}xX z=r>6jd?1sMlaq5g{VgjD5Uvc!XpqcyH|f@FEuHCY^c~KIg)W=-dbssJAIon#5o_*N z=57)!MbAsU+Xv znHSqAd)eV>=k>zaf!uoT1(c%L_lAO=FXxdIdj|*87Kh{=cJtTY$|Q5ORfDV|>XQbF z3SKF8pl+KBoYZNfN%bk-!C`Uas6>0*>~T_$>5HPr4HQoYvDF(%RV2FZZ+Vh=^^t#r zZvuL$JFoQeV&C?g0MSuVW*mLiygA}p zbe#o>G|b7{B*x#)&Z>gMpMK8RKRkdMlB?@gXe`r|rrTah$)CU21w;CvV>8WU+O7OF zNSXOMIni6Lf==;8#4(E5ysCcAxE8_rfh|uUtBTRy{jdR?nj%}!qtq4sudjCjUrFN% z^!@y15Voa84JFbgO6YSj}Lli!)r zniADm;AfcZ_r^RJAJWSj;>sihu|H284A_B*Kfz1LApCBlimA_l(wWe<01P=foX7u7 z(WT|&5wtCqCEc#U`YNQ%TF>HnXX8aj zNjjtPZC-pWRR8AtQtHqr47CME@5I)E2VN6YGO)9?RkwATsLmZ4UsRO)tjbIKjuyrgDA-KS zt&|{XS*A!>1mI#roDE4mT^mz3m};n;+!MLibAMZ1;}PHtO*Z6F(d+Q`A|y!g7vK%- zL8qm5B|qmP9}qMoj%MDUcuR(}m%pi)+d-`@Q??%{bEWq^_CEAF!2TfE3-M&m>K`=k ze7Q5A)$t`sz}f&#KP-8_rqoALlu`HTG-QY{yuMYwH1JcM0>zMtThH$s=J*Ud*EK_* zrB!KyAwxMjhVUivND*(Y)K~Wd0$hPCO$Lu%Ru?_|6_Xx>Weyeha6)LUkPi(vzwtQF+#CN zEBq7Q^ZVG#-FtJx?B(s53ApU#QXsjz+Cud`Pk(OiiGz(cKTf{vU#(BoBGq5yQh!j* zMfr26|6K)NmEwcrHaUsJQFA8`&;(fz64+IKVvYJYH-Kc1>rZI(opYvvgTeb~8QPLP z;{zZ;)zp+CB^lcL5#syzkT&1%Z*^W@gJx?wr_}%;rp; zTfQjVJYj=)I5pzNdmdVw#zP7AnnPm%^C=&2{2l$Sw_HolTTh>^UkxeR^(1Ln3xB>c zZP`!uhQ8lk|D4uv06OcSJH}ML^Pw3HiFDk7@*)JoJqt{3$MZwAZ|Z)b+5oWli)1_* zKkBwZ3@|!C%Q!|-qm_W+P}&Z??EUG7Q`vu;Iq|AJAC4WE`?AcBHHkZNzfO`q)!LP| zgaUXU;W6Q;K9JF4bqDE;fmFxn7=_#B^1@+CB$nm~@zoZ$kV+f%=LMmNJ0GUkfw|2F zzvz-3P+zgucKMgNZhv3eb8!5o!^SjkAllTvKjmXqwlMqW#MxhJXY|FV?@eSJhk*y? z{hO^t$p3;Cv%NLSgXW#uEu1(r(Na=JEr2~&{NI0t?-o?YTYrL*(ik(sJ zS~?NyVs5h3GY*j+CEoaZ2zO3`nSHuVmWW$mbtAZ9=9f!fTN99aR}QWp?n6>>lQ z-b8mS#CJ!6?SBi%yf@JV`>!GY{|)Kk9WW1A_asS~dAxKf|F*Ss2GC0SbBKMaH4b;G2j6MaP z2|19tgOw$FpPT>|k!v9#$f*0``KIt8F*-JACypCOGC#O@ngUED19n}bU${_oh1}aO zJB&r=K!pff4KjG?evB^?NyXAV^Y()T1POB0;iD-9Gnuz(K*~!F`=v$~%`J~$^?iby zuUcoANj)^?(>x5(mzUJXw$s%hzOV!I%f4nmy44`k0cVukjbQpplE!*Wm>lc6SYUQB z36gzK@TD?4wpNVJu+xxw2wZ){S;wo8J1lrZR4#0W?qJph79NMHKR&WX_va3r;j$uG ze)7C{g~yPR37Oq%nZgPUOB8A|3hiESrMp(ZXD>|dwJdkbm$}o=l=2P^35lElX!8je&w5JIGBrvX=fi z^}7rmm=_f+A_hpT-Tw^J&e(4cf!Um>k$OMVZY;<6t~4n}vjku^#|6Ei}=l4l$RoeB!#8lF?{cqK?%Y^>K+^` ziA{7o{9{>+4g@?-epl~3sqjGqa)byOu>#}<)WjC*gkgb5kC!R4j-qwo;A*QljMzfV zImp)dd|@)eOoEQNk(mlQ+b|1F>)OUIDcZGiUkCxfJSq5X0?^oyX?dO{1H{yBuxgn|ZE z!9n0CDU-QV&+hOzAAep|&)UI?^Dw&a9)Ft6j5#YMq*k#s8kBGzB8b4)%sKv)9s1xNeF zVyNMh6Qt-ZgGGS_N9<6HB1!n@KHQ~mz>;Mg1`>I=I{$@+F|6*XfrWW+F~2nc{#Bh+ z*nABoH~#0$(6tLcri9AD{;1u^z{Q*p`-5q2)6LM(R4-Tjt!-^(Byn1g{GO zzF()XS$7&-c^tuGXOar-q}uTIox&1-CxpKgYXnlgSepCT+;VZ;SV8t=Qpr3>6TKgI z>DRpbup2}GN8&%%T!@%#uBnDr!Qm+WYLV0L9U$f{x4z&=hfG82Cc^v$*C_t=VV~OQ zP;s_~>e(Nx4q#`!)eE$yPi{B_-CG_5`AX#KYY9O<~J$r~9u@2WMxSwvRs6K7in1GlH%?dv~{qW9#=U*?Vsq;Un3 z@eI8yJ?QyJSrRdUr)#oPN@#MG{~0g_BvE-|Pnv7ANQt?1TiyORFvv3d z&owK>69RyO@;#y4D*b$Rp4kgaf4YW{WZO^Ve3*ynr`1t9BSI(ORJWNHjf5 zcd2V=c_H#XxF;ge;N(+_fTn=pLT2eqyU}e{GvqlQgZaGqC_h4$WMEOXOPU6%x6w%yG6NSx+sdLeqOwZ6OYLge1=O;?7OlwU4QCiMeaeQcSD6_5Dm((wXIROPH z`4ezB-rScq>uo(F{b)Iv*1oE@%}O!0*1~I-cK%do@qpXN0k0_DpW-E=m-U3EKVQ%K z-1euXpnN_;0`7Ym%Os9QM?V9d=VsJISb$NrV*T;8Kca97zxOt7X1`i^1Yq7$u5>5n zWCBm=az8e-SNeEbwp~G+T4w%(j5SCf^0$0aoJMGqG-Ni82iN1O_eHOLSt0>p4PHL| zETL5|Y^!83+xWf98yR2FlaeyK?r9`X-}jdoog7hS<&u*AF|z!GslA!Yq8=3!3UIb9qbIe;ByzmcyXUUzNI ztlc0YRe(U>enI3B$w(^l_7pUrG7v}!Ij4n)jlm?z*1lX#sJ;>v zte6Gf({0bemb_V-y1{ux$^~|R{M&c~`Cb_A+{GZ$#%pV9 z@3*M)5w(v=61}a%KN*2s0U$cjNu6C@dRp$CQRSBwF3aZYCYpCfdT9Favqg-bA#0v@ zd5SXVnXlCSOzjWiKlCmIqE+^>A6CfZo9Du86sI*TweWNo5F#-TQ5=wf+o&3>v@|Gq zRFwik@&`@r6%!ki-*6r4Mcbm+eMk2%xnP0#cUVi1mfual)-3z%(XIpWKUZwcFGEws zb*+)xR2~&UTw)_#?^T2f*n0Am2Ndc=LXXl_c&U#BT))tJdNL>`rKh!9%^DK`#EX2T zDo4+?7W)Wd=;|5y4}>CB+qz5L5EauP56|t6gXF~S&&B#(C%363mArxCdRfi<82Wbi zS(9-wA4&{h=XP1+24qz)r)%n&6r)$NJwQsXG518#bjw9sR@G_oweRbZ$&zJ7MZKY) zBte|ZgwKJHAW6;0_m*@*K%Kt-)w#(+wo!|049oXr^0QIFjzjCNJ_$T#8n&01MxS|) zUwxp=SQ`Z{P2V8*>?zstMt!gvgsVaYJzDUKbZ z#APPu=0EIFGGrheuN6b2Kief#>4+znl#bUTzEY9aeqANKaWL(>fx4eEW8>kDyv;A+ zGYwN@H;ygUgC)d9Rq4F<&Y0{a;6|r)4G+-CZhZku5dGQTsf~fo-Wx*1jKOTohYsGr zwZZf!BUetuy03QUEbDjqt>Z;kMCh>$c(5~i))|}AvuxYNPa+JS2ZRZM|6F(tk0aj9 z(AAv|vv8|ab9t8o2sznhrZ!TH2t>m@pfjc6PICqb&^G_o?GH!InqgZi%a-wpD+v17 z*CP+Jk^S+EoL?2(TzvsKasv#_FEyoE3`l0MVGGu&xhWOw8N692-J@Ip&4j=F$HS_X-wkgtxAumiEBfyFyP4rn10t7bviZ>|O`cZ@~3sdvqqJvZCbyb;JJZ#A^EE^X{a z_7}eQ3v?mfN#)4b^GCX10sp+%x5kTL8#s$&t(Vv!+U-lo>i5@#6>h`n>8CsoRn3qX3;WM>&hF@Fd0@+(8x`AQ&@6Ecq zs~=Y8T)_kx3i131#@J0a_8o8Kyg_b935H8Y1h zghKa;D^BXJcIiM!rveQ{ei>aqeM{Uc`;ij*)6*48Q&IPNYG^|`XM zM5GCnbv@I6S@*7ppShJ-k5h@SIVVZtVelG*&50eh{ zpb1zlt!w?S(CU*lrQR7ni3*Jak_(#9P)n`OC8;Oi+4Wj{LMAHdYQdW(7k>7yZOP_i zub=h(=I0y^cSk@&PsV9ZRr!_KT5-Y*4}MfP5|23jq>ecVlq04349}^xp8?9+eh*uX zx#`(p4i<^ZqI}J0jN2l!YIg?s*%}*!TdL_te;U$M1G(PvP(&bgS64q$ez}*f6JBcF zzv}!1xLO;bEWXqoIkVZ|J)Qqy&<2Hyg(iy9GZ~s4l`h|CO7~M{vma}bYJ|+|;ZQ%U#1L22*L|+K2wARy6A@XURW0<=O*MdLzf<&_eb$@heGd?49@Mq9t$eUyn*s~6ri7+riw59h;O8%=sI{Pu#`&aXw98OMIg}{5^)pDIU;tmjhT_ew;U8#F<*94X0$jxyD@{S#7UDcs>LktU72_*CjTS)8DQzv z%eU1t8_dLUx1_kmUuoX2upXXIz`Er-+SjFB4m;(;38A5hc7^QA2Tl2QIaaKym-EM; zmDqJWOe?G9bA2GNWe2Dy$^X-Eol~q)IJ1c|d7w`#s}OV>_OMfe$SRP+649v*Oh=Q+ zccU5`5(OFG6w(v diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png index ff6827cd81d84d075748822840b020dcb8d80c20..85c5a24417cb0f5d767a098fd3595c2e81aee7bf 100644 GIT binary patch literal 14819 zcmW+-Wk6I-7rwi|(kb1b2+|=fA<_yGN;gP1(zT>?cegK%bccX+cO%{1`CUK$F*Enf zoPN%k*{y}GLW~`=np|SYm2Tr9=E#@FK29@>qmFK2F@T z*QC6N46c7;kg#Lm`W&XY{KM|!m#$`PCg!mgv8%)EyYGn{TszM^0InqPQo0N;)hUQu zb1FgGK(q9}!WRop11WC5T2!oyV;A~ zKZ~r&BHsPA9kOZ>jptO9EO%bpe|yD6cLQER`LB{7-?1QJ4-WA+rlD$A!5VkLT8;0^ z=9xw2Uhlv78(d;-SL3bgxC(H)Ws$bD6)o#hj%GG+!mnuH(Z~z?ljEPe#xD)#+-^JE z*8MQ;KXYo>O>!4VVt&bmHRw=c zlqGZb(xSpx9Rjt|S(WW6Z>xElnd#n?`G<#&0vasJ4N8tdA%pr9b}cXK%;twOpq~s9 zX51jlyppx7MnhN@W8-O5Qo1`?cq+WqqEyh~*^*Mw5uZ~uUTjRNr9Q6Q*$Dep_WG-! z%FCIlAV+TdB_6{=9-FJhlOKceS~M-mkV;;?EOEkG%{SrdoBETg1Ze(unGEo4W{* zkJ;RuLsb$EN{EiVa}r94sPbOA<|@vNwzfgubSHHjO^zM?nlfL6{N~KPBtj9#fsmVJ z9w|Aj5V5gw>u6ga{Abe>WJ6+b@pN>fe{}PAZDekjYE@464pa{po zm1Xkrgq$4Zt~|-1Jipq(LTH;hbsEYlgGSZYcQw*NRb3t?+Jd!??v7vAv7`fzd<+)Z z=U8-lr7#lj{MDK@ShVN}%XkP&dFH1U_MN_4 z?yVSZtD&X$o{PbqE%w=`oo|TxP0SX4$Lp9a_{RUaJG6S#AXnKOB;Fq8)51DXUX?;r zz(=&b@ofAkR3rVaW!B~VWfe zO7=**EC!ZbKW;KnB~e*fd1_cnbJ*28%>M?|i?Yqofu zuHt2%n3VKA%HhD&x!=@vwN=7RtlE6)r@^w)<(blY4ffE8#yu=Nd990M7%6s zjoTEPX@ZIeb=dqEaS&+z!CXYjb*r08>+Sfx@xUPs6SLicsmY!G5$ca#$_;Dv!lm4& zy9^ieU#IAk2Ayv~y z;0nE*rh4h&G*e8pd>L~ywONX9g}Jw$!PUWk%)n6}!zQ2*g0gy+8<*53wsaZ8;cUh< z^;L>$BaM4m$hBmLAcrSPdO^KB!gxES2RRODyc_2-obbCh)TMI0d8x*4-(}l)bR~h)Ly>Y3qAh#}|E;o*sH&Bc7vIMA;WxV^;7n6DY z@x94(Qs68QV8yP%G`QN|YfT&?EPKV7m!x#4#n&ffjp1jxF(x9|Cl|gnwKrpxAp-?eZ&E7fd~#dRJfWz8pQ=AT|CHRi~|L?tCiue8_Pj z#gk~dDYelw#`E@jr@bJ=if1Ndv0XWY`b_D z$7Hu1s5shg=fqgvbnmVo9Lv9WD~=EXN{7IrHe}(6Hp8WA4ykj`M~7g4 zS2ym|nEsFGN4#2>GZx1gt#d!jhPoeeAJSa?J=gnui?MxZso{QgUQP9nY-_)GOSUw4 zKT~dM2~DNH3vo#6Mab_ergGWt)z!|p2kZv%x8WT3v z!-U&;W~lGS>`H1u5i-YBc140$j@N04$J5dx8JV`hTG5-U8hRAgL*Og|VCBj0dVxMw z`dK~IX$xznoczG!+Gu+;yLw#o^x@p_nrI}Mjb*5Xp+(omaRTFh@YK>5xD^M8rgFX> zVP~)>mh=e?QYasKJmWhrLNM%6{;Wk#j%0A_JW^lpJ1i<}|1>$hL;Po8G2wz;-b1+0 z_r^y7tun~`E16sJ#KWV3t1Z8j9v{@#YmARv_|8%83nsd@uio4lLb5*qM2i{zjqYVh zmpNZEi(mYnWxU>+NR!E=cT?1O`lYpW_vOe!q_KO-k$zTH9#?z4+1e-KB^+J)&FSL8 zUV+H=QNLhTV&m>x8{Kf$7zVjAJa4xTk2i&jhk}7$8lN2-9s1RH0)4P(;ppG2!f(K6 z?I2kC8&CCfekJ~9cJ2E?kM~PlYzgNTiyTwKhue%AIZz5wU_0~hzm+yxuRF^l?(JQ} z^wbf(cClo2&6n1%yJ+@Cn}@!qJfwYUMCw{Eda_TVbG?djdp{=FI%V%j22bJyv- zBML%|XKTnQ5`c5R#j}#Cke=0Sv?0sUeEF+#5t&NDAGQ`1-8LCSRrZI?7>iO%p1+1j zoU`~5ZIS6LPpeEO(;W=u=v&tV1*%wWrLc?^-+y6Vp)T`OV||*{eaVu4vt`%!>|7MK zXumkb*{u>pt{S}3mh*IPR$SJ%+w=j}bgjLI zMTIjO3&{@wtf}(5K9mLsRAerdD zR-qeV$U8(^&6dhf+UI%^1@$05*IH{>3yB;^y=sqzjceGF6REt&7fR0BMxV>J?vbeB z3)K3dR3nPl&E)5v%Ta3!^s~Hav?b)8KJfplv`>q-yUL%<8|2Uk%HQ$a=1v%;aY2K4 zp!EbGlnXr`P+aB*1Lp``+9{I8FTH@qJs)x+Ukc&L+x5*5_W;~)h_)Yfo)xt31T8E| zMZUG&N!-^~nOP%M_mv6l{gK+tB)X+3Cyw)vpb4K6!1y@*5Kej?0 z(cF4&O|?rpUkF_+vlAnxkpeI{5>-{l5sAKa?CcH#9JRylil-yPnR;YUV1^JR z!d*ZI^e30hnil!{S}KnAbkBS)OTebZXHC6DJ>5C7!Jz7@tnRb(iL~+P2c+iYa2~<#&}3 zO?}R|(k(lwIlZzOCQ7?N@{OtJc8m-XVY%>r@nq_Drr9&Wg0BCf0jSls>$E)W^>}u^ zx&AEUwf(BK(+B>B&g-)IuG``1x`&9|Y{iL^HoJRneQCl z&7sGIh_qyvrBZy{5foQ0N*B@Daw=ZcB%r~~2FjLlhZ_deDr7&dEH6#^CC`eS|+7w;wYy8pr4ByME^;8lD z2_~Mxs^bgi0fCZ!&oxcfLpvO3G|DPV(#vjBXth1_GRv%`4;=ujz`)B z$E6OSjg8C*Ji4C_g`N>U0m~KIX*?5ZmxWiD?{=w8uD%wzOV~ytWm%b%b-6mrteoZ{ zTsv-SZDSq00Ckm4c$V@zvMxy3`ugYbv0R= zk#&gIYN9^__~9ap|H(HjX%*X0rwdHG-pER1I%kD_4s;~`b>+&t*_>OhbGV{+o*pP)*7vNB#*j=#)g<^{wAX)8`fkD) z55=>o|Iw{@yLBDokP4K9Vg|%!mQ_CU+s*iO0!bg4{A*92lPIM{m&#_>KpxlGYUK=$ zf!PY7`W`4PS-YlF;e;`1Ys&U#i2Tl%Y5fp>P69TIKEVS`0+;1V`SSy9- zW~_S-n0VXwk)EeDh2OzAiRnt45%_k4J|4xklEHpPIv&-F+O44kp?nAtWL5YXF?DCe zqGqOJ-=^QxAw-C?2B^p`-u#zT_vz!xx}@N>PSH*+L_FkMzk6Gm$79Ym3ztpWAtt`4 z8RI(8x-N0q>%nx-6^mv6WJ{9%+J_-Tb1gElK`MNd^XBjEw9T_5G*>*{0uZ@PxI3Mg z=XmHWY9@m(t-*B*K7EC1pT?bVCRsl-@CVw&Wo8XvDK8uyGNgZcFC zE7BP2ZhrY{PU11-_ls90rh|chlc>E|n1E>6gxhH9-PKV6W!s_NZ zKEvVXuhIliw9@=YCmH?%&y?%ArSn`P0wVmo=Xq?vP~OOZ`nH8!rHT#5aNELcEVZ8^ zWB+ToHeKs&*6Fl)Y1gg$&sQVlpYNc(e@jrGjK^%ejJ(d@chdPPl7j1O+*O2X#6PZ- zhu4{1Vq_ux)4yD0hK9k@9zPy2UG_WZO}F!+?&DV`_l)mRT<6dfIjCuHoWa)ZZ)d)n zS|S5)_e2cIlYVNrs*MVmLI$i&xD4R;tDA2Zfzif#I?Bf16dcqJ>@sicx)G-LhK$&u zDti#EOdO&mo1^*IN@{0atx`}w)tWfp>grc>CeuUL#!_GAjm&u7zwS7h$>UBn?=p-I{znMnjFgZa%Sc0<`Sz%ug9H@ znb%RRz}eP8@L3F-nZvTXPDD#%7>1-jf{SKpw|MzqeO zx*cVLOab?wUQ`@B$NgPak*s^pD++cj(GY6vwS@X-rK#MHukCmb<7Px?`ANbUU?(7e zBU&D-_40O%5X$4MM~32mFQeG$H{3GsVTt_XhM$eItVVVVOdmhyX6i z>daIAQCe}j;N2DKmH*#7HaKlEpO}ufZ!ON_LZU(WV^Jo_K=6>g^bw2UF(Edcn^9%IW?*N=gw+Zy83ey$m!fs7*@@lf%PZP~V)h9BVW>L$gy!oMW`*2=$pnqR^gFy{+Ud4+%r+_>6%ox~|7~zGD zBH8+}lx12Ovk8BuI7}0YzCglBs-o{N%&iI70r5Xs{Z9xD`l_x7n~L@D!|u{L2>-NX zo}efZaci{>U#`S1`iK75WME$;X4$MYNfPjy&@(QEJINKv8B_WluA?s`-yg z+SCVl5wAs2$^q!C)1)JI>fH8X52j+4kMly9GEyGp^_50;u}jfglQJRvvB??XDDr4c;&D33jxfOH@j8yYv(n(G7;p` z4^Rf0m&hR&Z8U z{)@APNPBgfeja$HI|$dAt4dzxC%LvNcR02IvY!JsF%{;dxOCKwg|B%~!#FWkL5Mh! zS+noOCOl91dBgoqn* zCeZ2_q56CD<}X` zffs=#2O|$=*)=yWUw1Ivg6u!6qLyBn->Gf);&!pYEA1xVpH2SKfIY8%hDpUJ>_Hy7 z#A=M>UcMP z*lLz?5g|K`fDvx<9BSIDQIrfOhu!mkB5=NtcAX6R1mm4uyW0MDq*7nR6R=GA#aq35 zeEstqp%su-ato`LB2u$xU?ir)Yp;8hcvVGc_Bzu3we3DGydsHDOrA2CWIZN=cL&me ze9}Mkd&cGLnd1=&hjm4ydnL_&>Su=#X20?i!cF1`Dm{~JB-vq@Abgh%uE75wYPh%a z2_{%}vsvXMjMJ}>f#7lr<2Lyv{3sL;t~mMZo2g~VveKTche*^e;w@>+%OG-#<35N} z&<2R@jAs9lG35Lq6o=R5QY%f=OiO-tYp>q=Ug%oh_>L+6X8G+hHWa1wSWcPKBINWy zMDBG5%4_(shfL~$Jk|aeY=b*=>T1bjw$DOeG=o|#Sp6tNDBDw={-Y-{YigUx>^)+G zN8Mk^%!^)c*Od$jCL`w7Lp+|DXrrg@%+I7vE}oiSAmZ5PUMK1f3%p3HXz9RV&;e$6 zTrd@@L>w;dK6yjLe@`jb{5|sVw*AZ=Lfl@Zt17t9b-tvCu+*2E^aKs^H!Rxx&nFoJ z1a~GQCIqAssCo+o*DaRD-dlTx@=o8V0so{pu+60LX1eA4(*lj3;tk;C6s(~cd+T|% zUn>7hdx}miHq0l6wG%J12zr(76Uhz&4cfmHd`@R|cMo>u>T4-502-pYE^l(f{T|Mt zgEDc^Cj^%#w9Dc7L7Q=*GS(+0dhd?_gGi!B9~nR<;^7|tOOFmUObm9S&1Tlt=_)1A z()8;IgWBHBId)rqGS&Ved^pZj=BEzTzWL;0e)6Ts1Ww}T-N9pI&@w{`fA^-qo2w_a z+Jt(y-zOosTI=ha#Q~eC!USNS;dQ@LcMfIsk=MZ74a=;mIY(KW)RSqfQ9& z8wYt9-JdTPx|XJQKSuy)d!v^1W`8T7zTI18GMvD=9KTOKWBL$s9`VWxBAVu&H!1sT znX7>c^%i4QiU@_i`0*yr+(a)k;!z&JVf4ETGA!nb1r-t5N(mDG1UZPmYAJJUYqZ64 zrh>HV5K-o121gF^R$+$$6xge6-dWtSIH8uCbDwGxhxZxeZG5K7fNzCIDfp}7@arwJx{_Y$}3UnA?=vK;%O8B{~p)^5; zU4W>4gc-N5g!Dca+dlBh=J*%_(C}L7x?a3b{>8Z)WU_*F;e+D~WK>Dt)0u?-d_V*2 z`IM{ltbhCHN1rj=6C19LBW`+j9A;B z^$s#tD=mwX?!3tUgpdRd?<_J_+fYly)m-LR7#FKmN@{uw|NZCQqaiOYzwUHXrCM0{ zdQisc0{01W1rfFGU+}kr0cvr#@A1k-HMMadC!UapLA=t(jSIVGIFKb*KvjmB^m)JY z4Q;U;q}lbSn~C<8iBoX!dczY6Fu!%fQTdVi{I8$|rht7`Ddgcy>97;*qAsa}Jl5_i z(nOH{t!Dp^Hf0{a{XD^3LrF715&&MyS1@m-Zz_RULjzAV6EW#GO^^e!mI}axR4H6d>#O04WVuwjJR|`B zq92?qb(#Z#JEguI?`@^L$+QSBZAu;y*I+Ueuqm7oY990dD_`f*k%TZuxU z&w_MvhH0*;y@ffSC43b0=+XuQD87$wZ)6E4%be5--2VL|N`*p4)^1ZWDxAi*7{9>= ze2#c;QR%$tSlsAJD0n9auO;J`;n$h?-7oL1*gb-aWN@>l(RW7`X(3Fm!ZuWyUNY|r+|W)Rq{&}?D=x5_45Q4YcNW#N{*kER)EZ;!@|3lH*Td zA{7jg2!KAXSJKsAv09&8#%rS1+V{TojD`9kpzFF)xA@WFgJA`ByY#?Ld^2)Q;cY@2L(U zG+Ard!>?mR_TyoI+9qNYd8*rO0!du_sQj+71XGWuEBmV@fFQa|&hw>~eS?@R#2Xd%rPFAh7S@9mHgfQ{HVgN^NK ztJUl2UlT<9{7{!U*kR}1`(|cv{!>&>T@)qeYw*w$O$k6 zWh`w7FI9`}q-fyr6+xPfL$7M6#uF2v0RCu{tW}bB6?eVV`*ujPTRa; zgK$VzRMCYJ*$OiXE}Gs7NnX)YGd_?7FS@I%#t60@@Ho__6P_z;!H21yDFJZkyza`b zW?Q;oH0`U{dhlI`fBLOYKPA{sV;=zvq_JGFi^GiIMVO}L^1s|21?W_L6+s+?u><%m zICq9ER0y=VV4uiLq+&(583o8%gAOODfN{MxxSP-cv zgXAk{eS!Qv1WF8qWZ7UJc3RyjgqaS)oh`UW;_ob=fC5VVon;R%{!3D>)w_h{I0%5& z!8?0L_!2i5Sk@^Xfvfw%;5o=AR|3ZExEOkm#mhgD*`NSJz3@^Ew&!I<&>zZ*)+5Ea zSWuC^=a{vF@Rtq9C2a4n_dD?30{B|b8S_855p5@-VAE(R^IPj)0gapErUU>W%~Tb$ zR|YOQaaDjSec za|3!x41^H?@f4fhOUz!Bdki)2P6^Qa#<~pWf3(uc_`id{xSMJ61sO32HWA?5AdbSl z0-UNx{tW45zyMl?vJqDg1kN08ga}1{B%m9wu9_y1#5OOq!h;MCfd9@$VH$@#BtH`? zv42JYa5u>8JrOu*c@SQMMxy57SUw2P)%GVa#Qt}%Ka{q42(!*uNhIt5Vm0z7`hOgO zqPj1Cf6tBpgVWCGQvLf}jIx~z%ek2GTIap`*Ijw49*(yFL(J}a+5xW1pTAlt5ihbGHc5me~{Jaa#Pkc4*odGP>2{zja?TBgm&eeSQSnL|6X6wy|VII9j< zJx@{f0EQ6poDMtA{fJ*`VL-$vUUeMjeU^}1QZZc3lbjypuB~uQ09{+SjNP!Sh9(JtBrwC;a62g3!Pi*Sa0tMVY$y%Dv)HMn5VzqFYCVjGvB^Qa zx8*Q^fmD~^E!V0v+xzn=WVnH%+g~mSc>{^0t%$)OL=ESXW)=zk54oZh_FD#wXE-as zfP}x|`m@_aI35tv$=g2uLrGi+ekp;Y+VJn_G&izYa|jVB+Xl*a#TD}Hk`#YbQIZ;tSs#;eh9f-L)%_pISt(cn0rLZiW5bvAmlROSgVAo zly@lroN4$-pnk{a=0cc`gbHGD=>ffDD_OEgDsMNRia?gNL%J2gvEo5kMO+M8iK+Qt zt3=CeGcXnylJ|@;X{vii{AY|X`}R_kBnc5gRQM)Pc||XD$Mg#Q*ka%}4?D0CV8h3K znL;Kmg$@}6rPC{7O(U*RrF&Cw$Y+X%38Zhhu`tvSl0R+v1%)LnyOLNUAc*SR!j$?@ zS5Yk9z&+zn@j;Q%gCkhf66zlz2W^DG0?fb;`HJXXaqauO>UzY(8Yx3E(3UD8%oE)_ zK}9M>#~usqtuW9IYR@sVTOovs6a9&8eLU>{_?tl0>cKZrgIqXM=cG}4>Na6?QLj7Aam7J z$Wfz5fc1Q=Qkyh7Fp8G{6zQaHo9l#ZZq>?N@*1rXECsy6gW_tZJMIZTS*FT>dj;FH z8$FU7qU%jmSBh*{>j9?d$BRMFh!s3(w3YLu?Q*KwbEq)5N<&%a9hbei6ceEeXa+56 z-jbBi**eQLCME#5d8^F~5}^Qj#Y{Pu21bJ?q)J3`74I8;?gi{CKz*>BV8&>*!-o+o zro`+ZKo;))NUsS{1s$I#S_QMNrYxunxanD`$bFy`^$5f=<@GkGvfYcsJCkZ=>`m8%a{N$GusJ=v7u`uu zIVg|=3a;x+DOx{>c+mQ6>x5Lygp{eAmk#Z(dX?QoBRmXhSr0!QX3E?LGzm1l<9A9b zyYXjbQaNvs;k|EH6s)%lH$`$#H{$9f@KzqB-);gpV{0a!?1*So?RTyYGmcuk`z$no zfAoI#EewjOSI(7x>;`+?0pv`(kLTUO<{2{`-CD0bBMiAvVK@fM8>%7~9T!9$h!y2r z--_v@qo44>m*QyI4l3iQ7HTu1Nlc>`C)KQH%E36 zSo1eCB70W~!+LsR(T0`#RL@h~3EenjU^@ws9t=7RhOO_J3D=i(zia77hP%h=MY}pO zX2U=rC-I0piY4-7W zv& zjB@+;#eZa9?F>M&-kFzUbiTcCQ_A8B(eB+&MP%9>CqowvW)&>_mlvoMI09pTif!6Nr zkPx(jw%#Val!gsH!n9A7IGEJ7vTs&W$eporM4|fyTT5S(9Ayw5-$3HF^3lm3BzzF( zh9~QA4jZ4GwgH7tg~bN--yygtdY$adADc#)lJ#gvMcK7xE?I;fcA;pDt1}7Ty2B0@ zd;`3%k^ZH}8)=$`?+|?~y>imf5K|z3#_8ps4qbe;lVT=*!kj`-9O4n{H|FKc17}MH zSpMwriA0M##+(zkaNNzw~Y$U47rM`dg4KM`)v^=hK`AkzZWYBOx^nJgq zY_#mo_}3;F3%DI09gS+aVGd}M6T-;`hFuxoL{s}g@b&f+9i&jcm?7CVUWOfiiCNE< zpo2HtkuTKNCOKn!V)l>(c|$W2A3BKUQ9bX2qETKY9I9(=cjxhap*=mV&$ctT>T9 zb9WL7vb^vKdOyz2zDwN!VavrreYAP<4>@~PU_ml<$nYmONY>M`RF zrjB<5whxB?9k@hZ@Q zx1`D>2W_lxl0S{-9XW0~j;Jv{iq^r_qA zA0|d=-MEx9^wo=wS1TmKstuT!_!0;wwfS0)ql#%Cq!?s2?{YrHbDDSKGsbre`uX%K z$#T1pDQPH5GZW^nypmOJfY}bqj}>-yyxjq48K-_uJMZ*XGxujG=JfNyC(?5~kF6$1 z2Hz0}=~cwRi@%4*VCsidgD_3}Mz5Eb5kqLB$iR*j&9^Bvm z7Q}ZLPvBwCe}&S~CVzU%68lyz_ClgpoC!X$b{bN^>*zkj_yQ9mgbO#;v_hdV_@(x4 zxS#9y>ktUDe?^JfJIZU%aXW@^7?jn;S|b)WvWI2hIf4YmCkSTiCQ_L5d!^u|=eyu> zQ)+ljaY0YGf60G4Yvl{TZ`wvsF~!XJi{c;rCX8?~M1ATboL?)vISgl$%fNM0^tSt{ z`Rz7>{!KRCayV1mHwco+LSLZxhlq7a@+*m12_bl@P)2nfb7(}os6MY}>T=|7yO-kr zB`clp%5U8zl`&Tgb&#=_3xV5>#$}yIo3&KNsA>ZaZ=Y8n=CS4YlGv8Z6kZh1NnTI_&+ z_nN5NZCdz}$aH5(ZI7~=pxXB!p{CU967Z~q43yfF3nokREL{w#Ls7*ckVU*6yO?0{ z37XEgLuz4+a#?b5N?^S0iHV8&rPVU$6GROctT+8L3;DjKU7~N>0L_U2j$G!X8>QZ9 zoBM3dI#up+=*p7%yoJ_R*=!UpBhGuJ!1G^b;X)RdOG}IJy60?9b;>(}w-CRuNwe3|j;2WT%VAQbqwC16vE{g zB^#u-KLA$g53U8jgCe$M(+lgM8j=7}Q6GRb9@I4kH{#sxN;KvcEk;@WP1z{T)}gKv z7LB5x6IXd?kZ;G~WEd2uX{XP*LgoX2VXhg+*uK2|EuH9yZUf8U zY!Chj!v!Y?HXCznA14}ks2k%vNHLw{syjr1J`(NZg0mkV$>O8CVQcGlA6e7rP!THld?HVQs`?0A#ktjMft zM~KO+7_rbYd4=Hm9zgciN+Omdkzhmi@bRF5f84CM6$oS*$oWK?Ptrnuf^-%Nu$Fpq z8xZFcw@fktNTLAp>m_OFLSw@u2{U}3{l;1=Ugy@JWm3_^k@NG6JAvqL;hO~KU$M4 zKJdN1KxrK7k6%h0gE+xgl#+F^P7e9rX>8DtNPu- ziJi+u*0QT!{!P&m8_^G7jV1XsPB=8;sGb;jjI=&0k6t_^hRXB@4Go7K%J=F}kn`sc zme8l0u?+$F%9=N9EZp$%_dw!{_(t=37Qd2RmXB4%M5tx^0uf97an~z0!+HAb`$)2g z5c2uM7Vl%6)WtYT*+O2!2rEG=cM=5#AdVc$chKbd=9L|y18)n?Z%HwD zmzVYqa_=q>=nLXXJ|ra9Zd>0KTY^6fGUrm5M_H}l*0iGMkdhn;!PY}9z@hM2G(3V+0P-JuJC{UM6BJrN2Aly96AyRMp-V7wzrqo4p@ zoZq%Ec~_ZgZ7{ujEkM2m`yCDh_6+>^!)`rN%_nyhj$xB7PYaV)@gcV5 zGDCn~@(2n(M$K*DVn1BSzK}t-H+=PjS#kVWm2|xpVINt}c`o??s`vrOmD4ZLI`&S# zEgi4gnCvrTix0pHREI^cKVtGlMD5ydPRiQjL##seU)RsOn~Tj-_N!OZNqv8k zS+!+>YHUcD?+LVzeNZdDb9YbQ2DSc0cc(_g6WRp}Jx z`IO$DJYMwWacbGy=E3MrmsFYSoK4J_v62+M6jc@Qp@l2LdOrexKWw4^K+esRh>eG3 Up{Sh9I{^HXk&qWJ6VvnkA4(fshyVZp literal 14773 zcmX9_bwHHQ(|_(r>5^`wQ@S~&1f)T_8>G7rK~mvMi?oO!NJ!^#2!fP!$ANSs-Mo*# z_x`%uo!OoF?Ck99%wC+9hB7`5H4Xp(_$n_HbN~Pf2LQ-2HWX}Wsa2~50ErM4g=cyJ zIs1!&IeL@17k^RB7q3Ld_TJO{V#Dkrp@2Pu^kTdmDJ>0L@DNMzq$5pzmqqpdcd9;V zk96yz2Ex*B0Vo}r6B7a8ou|1-)HD@#FO=U}CSxak6`Od4wo4}6a z<8(J_iLu7vi#+eJP1^ca3043osCidS0{}2uBP%h$F>=3ugUxAyPf9nakvEZLC~>dh zr{9zH`282PES$Km_dOZYZZAUFR-^zgqPw%Ix}k%KgKsfjOiHO(p_2U_#~<$`zHi>+JH zZY`Sa2aD?0KI8ai#hU($TKw&;ePJ@E%b}By_HK@81_H(dG8beiP+_;~XY~D9)dEMk zXn&cKja`xPVyW!x>(4pWGzNxaDUm_JXw;_#m@?I!G&2U6XX%|cWr#&Z;z2+%22k1# zG?rarOn5brqG|h=+GaNBtdFf;=yC3^osSb(XQMHDRm$fVO0-XkFn|3QO2v)FK>&G2 zGSeo{Z(KxPmBH#VnW8x{R^P)XW$sz0`JC`@^S$_sB#`NJ+JdqmXfkGz4VZinC@Bkz z47^znx)(I6{$Y9?ckSasHL$HNA2hwYbk<^$t3jc|s{Xb1iIWreUHD5Cxm)a&^yP^Z`cbr1w&I7FI{m1H02R?)OVe>fouO z0LmC3?+V_}pDidJ|6pDX&-|Dz`BC^TXnWjrWq-rhZXL;E z#;rC*nJ`^i?DDSOMDo&Ny2Sip+{3@0!~K|JdtOE|ToIU40OS?AJArTt{~}lGf*1&*&wv7##Swr=URc^j0GH1ZK{;j<^7k9KlhkvDP34i zsmg)u@6(>j*V*;#@)N}}LB*$J=h?hbMfFL>PcXw*vsO^&D!=I zteX77OJ-)>R_|oaM{QyPR-=PJh@Q&pPupHmQ;JCG)_64Mb(pq)UkKP`DJrdPCtz64 zd)gy)!a+6B7=n*Z1ciz#O}S;TqsBCO1yJU*OAGs`d8rNWrOU#gTc$MJbfuO$*Ly*c z@aCX9d7Gh)^Nfb~RC^jUt9gNGJJYS-@X@uH&^=B=8`SfH)a2?%LRO3Xcn_wG1n-G1 zJ|`cfX$$VbelB=jIr1J>O?p!dEGTZ=uGRB6kdmR0DlFMkCO>`R$$A<6BWZIDdPI0pfJ38N;e)SbCsIfJ(Cz6ShqD{U7 zDy_UPm7smc-t>VMhriVEmT`OrOhJ*Bg1VJM;LTa$8{z>CAeVYw*~AH1bTYjt_Y zSFisws`_P9WwhH^PhPD|;8m5kmnZwm$gWM21V)U#JXc+0M4!bjWFctMobpRcFz~bY z-zSG_U0b#`y?@t+4|ifLo^J5DxtiNrM*6SE9lTAAVg?v}&^_u+8Wj&hRp@gZTzNxY zoxo+6-jXhClST=pRQsLeSzk_O;~?Ii?UnK8u9*g=?4fE?)X|pZ0fVYym(#^znhp!C z>Zu-K+a0Ul#i?zp$~6!&*}RMno+-lLLU}i>`lX_>PJOdBxh@*#(HEQBkmoSKpa^75 z+N2B*=;b=aI8zB7MZf6h46>AGoqs!5>s2(EQ%)Z^`C9ey%h~ZHAA~>kQ#?Ith^bjd zMP|Nqkv)Gk4&7V`O!MxHG)FCI+_Tk#BKLc-irl4v@1Yt7ZhcxxZI9%GSIY)WxWF2F z+4;Ly(d{19S$8%DJD&3CNoC%joX|Q|a#~8X3Kfat8?m!RenYkC4GVfve2GoR&DIj9 zN?CL+I6#2B?G%E%;jo;ksN#{S&lvKaI4$Mk%qX)kH^LtM(lynxPxF_Sjqhd&alUmH)V{3ndO z`V=;!b%oQ%U|B@S6-Va`3t9GyL3Og0-Hi#BoNSE~C0d2kPi^E?Q*vQF0q2XixHM)Y zUT3y9`6W1+egLa7VDNG3xH}|tcdxsp;;QQ`!NLP^VRsnYsld;*nfv&feVB5Xzn`HC z>vW5>|EL(-;ZqWib{quGdIFz zdkH+lL->Z-wFJ>U-iEX&h8=yr&KEmQ5@4g>348Eyzh!#tM)qItt=(~vL=QK{L4deJ zj`x7kHPSrYm6S*k-D7rNbvxKxtK&AcUI?Xv4O(otM}ye*NPi=)<4{}iHCFA3#m%V5 zSN9J)rXog%skXv+KC}>1CF6md#`O)_=Gfpnd3VoUij(7rNmMn*MB>#PxG^VJkj6F7yohh z^DVvdCny~Z$jn>axiL>+_^q;*>zyY$chzYi-bE#1uV1uqEOmA|9~e*B!gkh?=N7)N z@cm`akAHiM#N;>*;g)gbhe)sO!fcY0?#&f=r-4(ylYVj5A!~)WP|52J9<;ouOlV)Z z<3@25ydp-jj_>^%e&7(g&-o>z?S-PhMh#cOZrn z>j*8I@HCYeAwc(V8nj@WmU|u6#Ao=rIL%bFuled_m9jw%#prj}5`=5RUu2tfuVL5$ z;NGLi7rX-(B}V2dYDUcj?W2Wu6}>9F_-*q0xaF`bm|cqqvcP$=l<9uoSnsEn*W46- zZ@ptu!nN^-$aN_3_D|CBKnOQKcy6Oi52}0VIHG`qf;1MnAmgyJ=gj`gQhu>gtIYlZ z;Y)YK6l&AI&IoC6WeLa~I9aE2Uxz>7sjSJp!i^PyHnGT>vRibZ0{nfeb-!xvWDnO> ziAPv-cPEyt-)EkNOQ$>b&xZP60vCv?z?eM-)mHe9B0!<+61Y`$ZSlbVeSqLxP*+LP z|K$ay@PF;GRg>?pdz9Z(kYi<O3Rqo6ulYDCSbB7V_BK#_-D4A62ie6-35Wvr@_BIJcf?Mobj+Vbx<9|$7GwhC zuE`izi-u3d?zxADvTrU%I+`bB!=A|T1CRv*PGz@Xov=G==A%IQzQ&R{l!W>>X#-xs zb2Gr}M(Frtlf+!3@Q*(GtXMv)$~iXU685qvxH^Vf?5CmN8 z7e4HkpLfe&Nj!f^jJ$H<724b}$$Kf8cX$HE;Pd<*zo{s6YT+Nxz-_;36Gzt?>ght| zH9vbUrtwF|s+$;+)B8Kk^ya^h?TnW{Wc^};AEukG$+im8Y1UmXHjPFkb~!+Je5Y+W z-b0_u16aHkRs4BQyk<0}H7&Q|H_DqjqEH{z{^Ucs%KMwDPKM9KcWp)}pBZ(yArn0G z{w#&_wd4?fIE`uyYlxqE7>D~dgMujt{#>t?O%@|nCT5i&Ty zI80O-b#E%@(Qs0L={}YIn*wu;6DU4i;tKuy(#5XGy-bDk>i5KizviZZK{ph_!(78% zTor~4*x6sMn^9p|ys10_!}c)_#y<_hebe^?0iK>7Dwl&#e=lYR-8^|@r9cbec`tQ( z8ayZ_cYFp_?R&4Qi&|Buef%t{1@qty`W$c14m!JbW2>?&+8 zhHhCVC|$@We2k(rXMOkV)WU?}+etglMdfso`MSpv?I$h-k=%6*dqVuo6VI|@Ju^JzA%LrC&u2+|Gn2x_ z?;2{QBH?w%b9PD7$?zk)WOh0{ZoGy_qc8C;BjvOVY4$r}OtE$D{;NFI}4N zl8#dy-W>K)XnGtpX?dSeetKdKX{$A|@T#JkIsdP2tujGp^BsF|g!A9a-Ro=R6wU{n zQ4rx6#EM_Vs`6uE;v!%Pp_cF8cy%pkaecm521=fH8W{FDVn^^Wv^n5Qy>H2(!^wx? z=7&S)#v9pNBs0z*5~Rt;HEyAIjRx0a{#ub2m-WA^(XOm8%SN&Ccf7+NOY^#%GG6#k zdrK31VkC#OS!L%0mNa*Vcl`L!d0;c$6xy1KfSt`XndM(_3mxr9f65oT!SqMb|HJ;Gg72FW#IcHQ$m@Pl$ufA1=_Xinaru0g?o|<-BsM^O zB_7|af4}oIS8y!wmOAqk!})omWZ)lT|Kk^8H>m?tG$jjel`};DqdvyIysN(keeRU& znYv*9)48kRsvU)+OMgJ0cm3D&p@H}P#yl(lj5a3lMeS%ywUg-}9gV8tSDT%uGX6Q< zC<7VL4kT364jw-GABdxVfA%$*%nZ*ntW%}9HiL;I*aIMWewf22&lvtte!1U>O+nYJ zHXbyQKEX+u%JT@BxZN2*RBf4@obPh*|fA|@H zRzhymDMy^PpYhyCS+IphY-y+?Q$lD z*W4U9aaGf|y>9p1$eqggQf-V*#)JcD3(dUm$7AsrTU+myXv`ZL6e6-y?Q?Y)uuBc` z9Zi^~eAwi(x#8>SEcFv%KuYO)EiHBySr5}~-tIxwuc65$X$!JuSm#*$rdE0WNzJuc zcuM-^`#^(Pv+Wn+CM%CQ$4!IGfIV@j^$)G%aZ}Gg zF2f}zyE@b}t!}N8yZupDh`;Dy&}7T*FbIsJd7eZ$3uS;&8nNZP^y)xfzef@1M z5e>$gwhd&VNj_hwYhfX+Z|*Z|Eu!;n0U#JnS88vzZJQbS+F56F$tz+cVJ9=+}co2%xEa1G(^rYbLhBAU= z6Ep}}2kC8G#w70~?TD^VsLNk%8_&8}-e}V~v%$kfm50r@=6yid6;qT4sr%Df@)gNs zR7+vpC?^js;+jwb9=6RwZ@dvTn{hEMLVPRLDnNy{s=etqwCu?6^Vi2$9jTqz7Wn8# z$7k35SR}K(vv^X->@)mKG%=tKyoq%gv~&^tdi`?D7;%q^Ti@-ojQTc*7T}=Dm*?a< zpH8)NKBFubls;ab*U1m}+t#n5Z|~p!uF2-vxUZ8rSm*b}_(~l%tM3cA@J`Eq&aht; zDoq<~8>DZM+r}-xmcK9nqX2*Jqm=p{-&%1_YOAsxAWy)~y&lm*^EA-Gu!ZWIhad!j7Zhi;X z7WkoE47uGVZ~lW&I5*3Sq;9*ZT+Xln=o~nj4{`i0r&LB0*)vg@hK;OkqD(zyPvN?W zrjp|`)iR_FW+Z~UswD=YvG1Wo811cJjSM7xjSRlJ&V1o(pMD|nHk?q4ev%T|D8uO< zx;Jlgny~$qAJ6s(w*Z}hrZmhmq6)YkN*bKc!z-7Ftt!J^&ZOA2=-6uAx;S3lJ4s{; zg`^E$^_GEN=zU=Lo%J971S32pMSTOneXg)7Xe1M16VoIoDfVpxi2cTpTi#3X_pzdnj}FoRHlaCF`(8? z3=;joc;}Tj(EUSVb#hkuiagRii23hIOZCUdfUog@hQx9G&3iLH`{O|HR@-}J&fS3l zsQyXBZG(ggwh!@{gx-a9`I%hN!JPCIfrl-ptDBv{wc0}4jdi!AN&+Rf9TVNqgie2%{0|p|;y**GLR<<|G| zZen^nE3@_0WtSJ|gQ&a?AG?JLrZ%~V(lpBUJ*VvF82lEzk9LQYMzNvt^IRC(7&N8f zM6_-B*V}=JxGk&=)(w7Ka4``h(5BgB8u~MJSxu_#oNyj+ZwhZRl(QwBsw-GLRL>5| z;BzF6Krc7L(+H>S?oY2jBWzkqepvu3+Nxo)ivxMF_a3=_6Xc?2m=%n~A#H1|`wdqPLXFi6yESmf=zFzUlaX1E5A8R;u;B0DDZ>eU@E0-wEE0Plw#LCoOh;WG7X zJIuqJPv5ntN>o!6R^^ET5QnUR!FC%n@1r<{_`L9sDPX?UFGW_F`ICJG?|wYqxZ@vJ zDeUH=LzYvo$9RXN4nN%foH)9do(0N&9o-)fTb$}EJfKP{Cf{3$V2$dHf({&U7W-W& zJ#O9#D*z>#eu|;xi|{QAIe-sRjY{e6L@PIaBPs?7^G+Jt>YfNy6r~L|{i;}Pu>dAZ zey31TG|!p{nHyXG4wP!cWb)#oK(^{!Z=kL!Z`xm{2qg;TL-fl3#{4QK4ShV?c)PTa zZcX-Xtke{E2&y!0=RE1%pO;64uu%{|OPw2Y*7xUSQk%-D-=o@yti6diALA^8somV+ ztBsEjcX-*jSP;Etdb7!n+?Dd4>!0}~yA4qj)Cy{Ep_fza`hNS9R%i>jZ2utuXXI(z z4lb5m1IFP?PCh4?iXJl@Wn7sb&?RYx7u z;~}c*+5-a))3^q<&?Y`6xl5cRUO2V) zu7S^aULOWS_QMIL8I@#l8)RXEr}6iBZm{p9$~iK+<~|G13PPh<%T5iQG;EZ05)p}+ zq*Ztph<2Gd+}Bc(HM?;ayhWqIxyyhJtRGkC+IHk+X&;uDyr&As`7Ti!Xot6aX0o2w zj957wa+q7(>A%2&!l-=9Q?u_(QVs$;uV0%O4m}&FMh96!=I{)UH%MU1(+8z&WqjjyoY^4Vj5KbE?&8#rBEdQW zQ)07NY574>-ZQWz9EidQapWXtj00w;@qlLj*Aa}W*k~PDKDFsBxwcfQdoEV7+@2!} znC+s^$C8?8O7Hs~MoL8vI{(&5>3sx1oc5M2GK>s!1-xzLdz?r5| z@EhI4g&UpNp=Z3D>cF962G-_PbjNhw;F1OK-D983!v6wk(acV{uk#TIv(0C+ zaJWe)_hyA3ZYXZ`nRn_XXi+DUJ3!!S?WNK)! zVMpMok7Q4U6szRd>88>Sh0KHAL>r@ZPHuMbap`KQuZIPZ#Pk#TNh7TgjC zVV&UmUO&Czrg=}? zEPwDcEZoEian6*>WIR3#5bQ~Ic)Xb}&x@k~zK@ritIJIp5QGk&(Dp}6`RrU7E|yvt zre(Q_;x~Hs^Qnp^(8^V->fKoxKa^mhXQIpN{P8^>$(X-1GDyU_L;-GcK~ zK{>jyj{tnyHF=Z5E{{vT?=7k71T#=-H_jegMYb!GdXxO00T$Zj=ydmx@%9JlvF+(r zVSFh3<5Wjujxe)s{CdFg7B>$XOHv|wb;D!I5RB2%9b-{1%}gmFNrHT6 z?e@PsxtBTZi}`+w3j~M!^{BTPez{ipTKK*t3SCJ{85%IfjX?R{e^2}w$PZL!+Wop( z4ON!MYEg$t5+T7-hk3%_9b3z6ausBL+}d4n|8aZ1WKr1v1|? zby{vvT3$s>L#Aee7YctNzK-38puMXn92{cwb94#=^dEl&olcPjtQ3z znk-+HJ8aEf*~S3%FZuLs!)&M10LXNNlyK?P7H3~miCsrW+R`}tDo4;i3m{Wo9HU$OTO~x?A<8q zIWmrP9$NzP?lKO$`dD(^e2d-j0fm#K z=qMo0sDI@K$1~QhIGrASl(3{LesdrhyTJi zgH|lZi4@?N^X$?;KSLgV6BKiEkS29;ML$|lObLZ_UCJ+1_8o(}kNGP?qla*aXT^z^ zUl5nh9i+fbfYSVD2w=hqaC{2+1;ECN&>I=vhg}q0*iJ7i8x0tJy!HH{ru+0mRO0t3 z=p}>8>6b5q@$L^xOVwyb{}?6f@^?^Dy7Bd2RoXj5yEhnZD=?0B8Lt1Fe;5t~T^T%4 zN41CHPx;%hyDreH9@Ud0PcmLq?75>keE4m0Odl*K)qv~gYf(-Vhg0(G67qU;YKX$p`|45aCQ}9i@P0{w1 zMv6nJ0o5XehSJ$^j>>jWjfaHpJI^#Z_~uOlwC5>k&Bdf!dYj?hWHz+xgv%J*A<8g@ zAp%Q)Be&?zt^!xqTFirX{h^Zos{2AxG{}SXBNTZ0bi>kuq16^2LaQ8z*vA_#-p6)Z0-G$1WSN3bp5o&36e z^4sZ8jXc2oIPS2E_%4tKDCkx?J@Ktjn~s_!iel3Q_|KEz%BP!nJOIac*mTPWyXDJ1 zDd@T8vx_P{6M#_^YPOpq!gKahLjW$rt2TWtE3s36BZ>w5S;0jMiEH(#Soyv{A=C-n z2So1G2qh_Yn70_n9NGa|*6lm%$h<70tz6EtzYb44+R)JgMx9YHUH=T`nDT(%6W^yJ zw#^d2QY}!{f#L(}#HC%v&zXD%(b2@U;*=i=18FLjQMCc@jkdUnRXPFlh9wK=V$~a0 z2wa?Mg+I#to(ev9odV8_DD+RaDQdwq@TMxBkl+%!Xq zY~Y^vcR=iZVUlS*{@)l|TuQI}TU|4~#A5{L&(S?BCky@CU#;;2Y59e396ZsvyomIJ zp-rBxhqdil-~prhC`Y;}xvTn*l_qfb@(O`k+_^kQgS|y>Supc6#DAu^|7dyf{?I@7 z@pD~(F)!uIVb|i<|K=k4=SY!>t2f^i!G$UYiD5DCO2MWfkaXD>573g0Q0cW7`|MXx z^WhC9{6AK86t#yC%W}X>E1|dmw1MLX6po{E-7C!*|G%kAZiVQ{bw>a#&Nvt#gh5Am zrHp*}3=Q@Zz2EKimx9#@W*z|B$P(wH$nu97DGWMOG=f4-fJhUZg$zPV$m@bc6y=jX zimwI=p7Vq+y7aH`+#Al&AR7+nhslHiy_X(1bN1L|xWUi>)B)nq|G_-u22${x;Ml)8 zf`Cf9o*h0c-A^-7y@}KR41cNayIv=V!AqK5`wOH4{m-|^?*)EhCE!bwy&l)!IgO;u zUe}JTn*Q9$FQOJjfmn?B1~J#+QzX6&&%h_FL_|B}^*pDP_oXUxG9zg4f;wN*Qhm|n z1(U0{B5W*&$?9|vpQI{`A^^Atx}>ebWFElg4k=I_Vd{+{_Co`JSQaQH@SNQ6%mzo2 z%@7R}U_^fhhXB|R2p#~#KGuAO2K;y)LreM%f(HAw<+aNBKqjn-2I%4GkNoWh)s1=w zB$f5-K<1R)mRSbD2U_lm_wZRXUuZ{dJXY3!zQW!%TPk_=W z7#7V|7;#`TizT=uToIgGA6$yC2Tr7SKue?L_z~YMjdK6@eW58D5{OvuTouO#rxY2w z_1;^@T}JW<|1)amP8Z73h{Swp$hXoiC|nRX111Db%mPp7&bMC8XeAk?}>hkk^z5Vvl7zd|-qs zLL*(CWA*DYna{bP01GHU;U00dPCU-H0Itiq$E9!o83F4`iyI?c{x#lnJpnkHyRESh zHYS_|+ouFC+@}2QfBLC1j^ye<=}h+NAhmcPM<;L83otmM6;F)B!-Q+#Ec|t*#B@P^ zpPb7!@Syqto>0G6NS@R=ioowQqt!szH_;_^QUNgfL0m-4OU%Tf1G!|_%HtR!}RbFB8dc%il(5w_d*w*BSxmQQpMSU zElNk|syq3kzX5w862J$O=D+{S%^^W6Yp6?I6*qkrYr0<%N%&gH8U%KqY)b%b zk<+>y-1Z+|oyY89V#3dVwfC@%{C{lSTswmI22!A2xhweat}R|X{;iQCz(Ia8%<(xE zAb6RL-VeScg*2zW9h56l1Z%Ou%vhjcj*15dEF1ZwpPI&zL(6AKbOC^j49}7eP|9FN zn>kj`LyyM=0N&$4Ig>#-mZB!Wo}3z(>$AD1s^AKejRL1|B#gOos4Kht|DHdA^e4lP zW}_g8HQ$!QK9AL70iPHhZmNFX3PB|WSxF2r!41pEIP%o%j@Zjr0A6dcE}lUG)U4CX zsQ@enSP4*)NA`sf#*Um7{&j<2MUhEwovx>ZIDSImbqgxb22`UaDfMazFg+0XouIyq zGgLZZZ=XxbJ|)4noc}lU3{}l_!gKH%8)3rB334HWOqbbw8BKcg@rw19t<_Y&1Kp+t zG<7_Q`3lc+gG6vmUvCG}7aIcB3CG-I#k$}cEPVOr&lnrL2vL@o39o$oJefUsie2Zy z`MOhG=Vuv=Y46{?@)d=vkK4+T0OE=Kq24!2j0NcHfqTkOIBdH2h{BcY8GzN0RZD`$ z=tkRXbwM1j|55o#){*`-F%qz59oc3jebiqy@?{L&WBJqeLUt*HvCMS(Oq%~j2sAFo zm=yJtCzR-DvBfZdiY>f|=c?q$i1^3_gXchO-ohg}kX3^f*Amp|yA3GnvDfdswF8zq z1J2!3FSHNO1mM^w()?oZE@wPv4Nf$155eNK99%R&J0vuHd^>%bfKkA%~-cjZxv(GpMo>Qp>uZvpIAoXSW`hl z&-F!7HzP!eu8TS*TB&w}t4oE6te&b7`Oo34rkeculweJdb5cA&gvtLFImP0mGYcv$ zO#=#E&oSb;PTz`4R!+j6$(Gu;M@ZNEi0m&KOk|*f3E#&Bh6ScJn+qW@_0_p&Y4UIL z3X#@vn}K{LDZm6&%wd$o-?U&1z^|aL%(nSAS+STzk`=zS;TnljSlVobxnJy!;*dseIhv0ceXjV-ELZvanFm*2qLGd^ z_vWI(SS1p>ig8On2%yafyhA3Jf#WjrX$Eo%{U?cQVhGprg0o=tY_XZlh zo~G0IBsgDhCaVTCJp_Z#`B6zjZHKm$+YEYWdc^#Po6nb>SD9LqjVsX-YM zIzW|G%O$0i6OE&M2WPphHD!hhuVBq9>COs8%=N7WYN+sX{N)-CrndqsoWzTkUEpdE z!J=nAiBY{apWaQJ4ygV-Xv>xNIDr?flt&B9Ot5c^|A$UH_@pEwz4|uz_;vOh9)NB| zTw@vOFi z%*l9Uwi1cpIb(!fz&Njg(J#t7p!x(fB7LyFni1mgvsgz|aTGZAqU~)6y+dO+FD#qT z0ZMk+pe7sMo0$M(qs|j|ENE0|28dajj#-P(qg!kdF|pdGk6^gNCw_-Zxp!G@W>`d( z)VP+!+NzV2lNhpnJx>6qz@$V%T7E*k$T?g~&g!RMo0}!Rodc!xuO**Xl!EVRYQ7;= zMz`_oY4N&WU4JQpX}=Jpk{%^5C8?)IrW}5D6vaaGw4d*6CFdEa;pxxQE_N1&D?Z7> zb0g4j5LmTjd^NU`LHA%kHrw33sgBUrq*IUn!oIUjZ72`z(eQhuvu<9+Nye;LIiv$kdq-6DZxV>D=@FI{>A-3 z0s`}m*8oa-J0-C6Q#xV75{Y!;I}*8wccI$cpGc89%XJchl(F>l8pS$@{N1*yW$?9y z9-y}3DX`lKvz_tt*i*ayM6SpSFGeyGi5S6z*Y1pd^FknH7Qzg(ZFc`PxOyvLX^rcA zwVc!sT8TSV^?mHzE-%gl& z{MSM3@RZD76sTu)zt-`6YfSMM$bhEmfuR8bW|_qj4W^2PLgd^cZRU5&B?cR}9n7*< zOKOO5pHQ_#Z6;3y|rUi&|~zZ>m4-~>cxm+ zZl4uvWMet(CnkbCEqn7@zqBW1%(38y^e|}ed7F0ODUp>#uHVCIPUzFqHSGFp;PEPl z1HiEybAck{mh75%Pr9c%kBIz@wl)N-t#qLBr~(I%Y1{kayywhbX|9A zak}Q40`s$L=3@?5qS;m%70yR#7Ltjpf%IxWV14TqNTOtgAF59Rb`^O9pyw*6aiT4< zG@UqwH)=nVBs&NizPxUM^^x{J@;@;t9#4jHrm;HLg+@#%_KLjM3Q3 zw_a6pqz9G|N%qis?SpyVpX9OZ&?d%d&yU`}dsM5)h14*~%9rcB1)XXTk%r80lT?2d z!_qPIcx}f_NL6jD1hfhNE!)tR6#Sgylzc$L0uH!kw%M`#u%d&NAJf+`r3pisp975+ zg%p8hViBfq{YV9T(d!uD2_yYFNufz#FDG*7WwA2%Dg3bZ#Qj@Te{L#v*vd(>710KEaqDF%`bRc7t*562aTgFl zn{;vLIQYq4u!!QMOX|8~)FlE3-(HcobNr`N*Iel`@3$1eA2SKKGQ-u1M!w(@t5Ccc zCuLHto7yg7^$w4_&?v~oUeN~(Kqn*qkFP;>U24+EFc1>|49NTd9*VB5mDx9H z(N_Q9?AB^Eo%W?I8Cg}|XVM~rHk}&JCNX_`Unp9yv&+cr+xOY(qx2+98++Mn)VM_F zYnu&5v7Tf!H;StO4QIcJl%NqR83FKBJ!gM`i)HwUoUEnWV8#yJ2YkluXY4ZVdra=| z2y8d5uUX(`j*GH0`t}*feGVAB*j01v7ARBR9aSSn)8az+P^_;p`*&E>@gzvdL=~TH z*+uee8KZk(8ygvrT2op%$^?Bi0Qw^Ug7>R44f}N@T`yIOpwh$MpzMEQJsL2zMA1Dy z7)uQE57mHPbK6`Y4vkYcLj9hUMiq1+?a;leI;Zfk%A_m{WzsOW+tu%4enG<+- z3UB&>FQ4`~rtNJ%kgl^`Pxy(V6o59h)FDh%&eOUKzw~XPE52}7axdP1%~+u8fsdEp z&za_svOOA=b>;KGF2;ntz+T>&%%ijqql!4wCHxzLub%Wa6U5xM4ex6A0 z2kz%vX$awU)x7*Ig~l8SBo+}o`EIM)h*yl4L6flFQaP2k8??_^(yw#?)m8+0jR!U| ztaYE&Oud^m&oQ_@Y*^1%ygO@1`+DM!t3V0CBTn{t9*}3US1qp2nf@D3p3|<|CuFas z@?2W8Rzs{%oAm^Bz z_uMf{gTqw{q9+8UtZB&O7pq{r7sEoGL(WdL71nj@V9ePQIwak9R(_}!LIzA9|x-ujIE zQvBQ=DyD!d3tXFe`1D4{Tv z(v>!S%DX!rT?j^!qxg(G=mK1yi)XlwuG+uy%$+Fo<$0Cv4QKLx9+#LKD{EJ@{Ddd3 z07flZuh0daXYdh{6LYq?&DCJ$m>3t2Jt*6SvgLj>PSXiGWJtd2?OSS$NO@=kLv> Date: Fri, 29 Aug 2025 15:39:58 +0200 Subject: [PATCH 012/155] feat: add SafeConnect --- bec_widgets/utils/bec_widget.py | 9 +++--- bec_widgets/utils/error_popups.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 67a4c5e9e..34a80ee2a 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -11,7 +11,7 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig -from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy @@ -61,7 +61,6 @@ def __init__( ) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() @@ -70,10 +69,10 @@ def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() if hasattr(qapp, "theme"): - qapp.theme.theme_changed.connect(self._update_theme) + SafeConnect(self, qapp.theme.theme_changed, self._update_theme) - @SafeSlot(str, verify_sender=True) - @SafeSlot(verify_sender=True) + @SafeSlot(str) + @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index d2ead3dd1..730fcdce6 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -2,7 +2,9 @@ import sys import traceback +import shiboken6 from bec_lib.logger import bec_logger +from louie.saferef import safe_ref from qtpy.QtCore import Property, QObject, Qt, Signal, Slot from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget @@ -90,6 +92,52 @@ def __call__(self): return decorator +def _safe_connect_slot(weak_instance, weak_slot, *connect_args): + """Internal function used by SafeConnect to handle weak references to slots.""" + instance = weak_instance() + slot_func = weak_slot() + + # Check if the python object has already been garbage collected + if instance is None or slot_func is None: + return + + # Check if the python object has already been marked for deletion + if getattr(instance, "_destroyed", False): + return + + # Check if the C++ object is still valid + if not shiboken6.isValid(instance): + return + + if connect_args: + slot_func(*connect_args) + slot_func() + + +def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name + """ + Method to safely handle Qt signal-slot connections. The python object is only forwarded + as a weak reference to avoid stale objects. + + Args: + instance: The instance to connect. + signal: The signal to connect to. + slot: The slot to connect. + + Example: + >>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme) + + """ + weak_instance = safe_ref(instance) + weak_slot = safe_ref(slot) + + # Create a partial function that will check weak references before calling the actual slot + safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot) + + # Connect the signal to the safe connect slot wrapper + return signal.connect(safe_slot) + + def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name """Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot to the passed function, to display errors instead of potentially raising an exception From 3dd688540e4a716ae7655c8e508bad617a80774f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 24 Sep 2025 12:31:07 -0500 Subject: [PATCH 013/155] feat(busy_loader): busy loader added to bec widget base class --- bec_widgets/utils/bec_widget.py | 109 ++++++++++++ bec_widgets/utils/busy_loader.py | 253 +++++++++++++++++++++++++++ tests/unit_tests/test_busy_loader.py | 145 +++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 bec_widgets/utils/busy_loader.py create mode 100644 tests/unit_tests/test_busy_loader.py diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 34a80ee2a..e4e72e525 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -36,6 +36,8 @@ def __init__( config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, + start_busy: bool = False, + busy_text: str = "Loading…", parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 **kwargs, ): @@ -65,6 +67,20 @@ def __init__( logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + # Initialize optional busy loader overlay utility (lazy by default) + self._busy_overlay = None + self._loading = False + if start_busy and isinstance(self, QWidget): + try: + overlay = self._ensure_busy_overlay(busy_text=busy_text) + if overlay is not None: + overlay.setGeometry(self.rect()) + overlay.raise_() + overlay.show() + self._loading = True + except Exception as exc: + logger.debug(f"Busy loader init skipped: {exc}") + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() @@ -81,8 +97,77 @@ def _update_theme(self, theme: str | None = None): theme = qapp.theme.theme else: theme = "dark" + self._update_overlay_theme(theme) self.apply_theme(theme) + def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"): + """Create the busy overlay on demand and cache it in _busy_overlay. + Returns the overlay instance or None if not a QWidget. + """ + if not isinstance(self, QWidget): + return None + overlay = getattr(self, "_busy_overlay", None) + if overlay is None: + from bec_widgets.utils.busy_loader import install_busy_loader + + overlay = install_busy_loader(self, text=busy_text, start_loading=False) + self._busy_overlay = overlay + return overlay + + def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None: + """Create and attach the loading overlay to this widget if QWidget is present.""" + if not isinstance(self, QWidget): + return + self._ensure_busy_overlay(busy_text=busy_text) + if start_busy and self._busy_overlay is not None: + self._busy_overlay.setGeometry(self.rect()) + self._busy_overlay.raise_() + self._busy_overlay.show() + + def set_busy(self, enabled: bool, text: str | None = None) -> None: + """ + Enable/disable the loading overlay. Optionally update the text. + + Args: + enabled(bool): Whether to enable the loading overlay. + text(str, optional): The text to display on the overlay. If None, the text is not changed. + """ + if not isinstance(self, QWidget): + return + if getattr(self, "_busy_overlay", None) is None: + self._ensure_busy_overlay(busy_text=text or "Loading…") + if text is not None: + self.set_busy_text(text) + if enabled: + self._busy_overlay.setGeometry(self.rect()) + self._busy_overlay.raise_() + self._busy_overlay.show() + else: + self._busy_overlay.hide() + self._loading = bool(enabled) + + def is_busy(self) -> bool: + """ + Check if the loading overlay is enabled. + + Returns: + bool: True if the loading overlay is enabled, False otherwise. + """ + return bool(getattr(self, "_loading", False)) + + def set_busy_text(self, text: str) -> None: + """ + Update the text on the loading overlay. + + Args: + text(str): The text to display on the overlay. + """ + overlay = getattr(self, "_busy_overlay", None) + if overlay is None: + overlay = self._ensure_busy_overlay(busy_text=text) + if overlay is not None: + overlay.set_text(text) + @SafeSlot(str) def apply_theme(self, theme: str): """ @@ -92,6 +177,14 @@ def apply_theme(self, theme: str): theme(str, optional): The theme to be applied. """ + def _update_overlay_theme(self, theme: str): + try: + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and hasattr(overlay, "update_palette"): + overlay.update_palette() + except Exception: + logger.warning(f"Failed to apply theme {theme} to {self}") + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) @@ -150,6 +243,22 @@ def cleanup(self): child.close() child.deleteLater() + # Tear down busy overlay explicitly to stop spinner and remove filters + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and shiboken6.isValid(overlay): + try: + overlay.hide() + filt = getattr(overlay, "_filter", None) + if filt is not None and shiboken6.isValid(filt): + try: + self.removeEventFilter(filt) + except Exception as exc: + logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + overlay.deleteLater() + except Exception as exc: + logger.warning(f"Failed to delete busy overlay: {exc}") + self._busy_overlay = None + def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" try: diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py new file mode 100644 index 000000000..2305170e4 --- /dev/null +++ b/bec_widgets/utils/busy_loader.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from qtpy.QtGui import QColor, QFont +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + + +class _OverlayEventFilter(QObject): + """Keeps the overlay sized and stacked over its target widget.""" + + def __init__(self, target: QWidget, overlay: QWidget): + super().__init__(target) + self._target = target + self._overlay = overlay + + def eventFilter(self, obj, event): + if obj is self._target and event.type() in ( + QEvent.Resize, + QEvent.Show, + QEvent.LayoutRequest, + QEvent.Move, + ): + self._overlay.setGeometry(self._target.rect()) + self._overlay.raise_() + return False + + +class BusyLoaderOverlay(QWidget): + """ + A semi-transparent scrim with centered text and an animated spinner. + Call show()/hide() directly, or use via `install_busy_loader(...)`. + + Args: + parent(QWidget): The parent widget to overlay. + text(str): Initial text to display. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + + def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs): + super().__init__(parent=parent, **kwargs) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(False) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self._opacity = opacity + + self._label = QLabel(text, self) + self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + f = QFont(self._label.font()) + f.setBold(True) + f.setPointSize(f.pointSize() + 1) + self._label.setFont(f) + + self._spinner = SpinnerWidget(self) + self._spinner.setFixedSize(42, 42) + + lay = QVBoxLayout(self) + lay.setContentsMargins(24, 24, 24, 24) + lay.setSpacing(10) + lay.addStretch(1) + lay.addWidget(self._spinner, 0, Qt.AlignHCenter) + lay.addWidget(self._label, 0, Qt.AlignHCenter) + lay.addStretch(1) + + self._frame = QFrame(self) + self._frame.setObjectName("busyFrame") + self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self._frame.lower() + + # Defaults + self._scrim_color = QColor(0, 0, 0, 110) + self._label_color = QColor(240, 240, 240) + self.update_palette() + + # Start hidden; interactions beneath are blocked while visible + self.hide() + + # --- API --- + def set_text(self, text: str): + """ + Update the overlay text. + + Args: + text(str): The text to display on the overlay. + """ + self._label.setText(text) + + def set_opacity(self, opacity: float): + """ + Set overlay opacity (0..1). + + Args: + opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque). + """ + self._opacity = max(0.0, min(1.0, float(opacity))) + # Re-apply alpha using the current theme color + if isinstance(self._scrim_color, QColor): + base = QColor(self._scrim_color) + base.setAlpha(int(255 * self._opacity)) + self._scrim_color = base + self.update() + + def update_palette(self): + """ + Update colors from the current application theme. + """ + app = QApplication.instance() + if hasattr(app, "theme"): + theme = app.theme # type: ignore[attr-defined] + self._bg = theme.color("BORDER") + self._fg = theme.color("FG") + self._primary = theme.color("PRIMARY") + else: + # Fallback neutrals + self._bg = QColor(30, 30, 30) + self._fg = QColor(230, 230, 230) + # Semi-transparent scrim derived from bg + self._scrim_color = QColor(self._bg) + self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) + self._spinner.update() + fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg) + self._label.setStyleSheet(f"color: {fg_hex};") + self._frame.setStyleSheet( + f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + ) + self.update() + + # --- QWidget overrides --- + def showEvent(self, e): + self._spinner.start() + super().showEvent(e) + + def hideEvent(self, e): + self._spinner.stop() + super().hideEvent(e) + + def resizeEvent(self, e): + super().resizeEvent(e) + r = self.rect().adjusted(10, 10, -10, -10) + self._frame.setGeometry(r) + + def paintEvent(self, e): + super().paintEvent(e) + + +def install_busy_loader( + target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35 +) -> BusyLoaderOverlay: + """ + Attach a BusyLoaderOverlay to `target` and keep it sized and stacked. + + Args: + target(QWidget): The widget to overlay. + text(str): Initial text to display. + start_loading(bool): If True, show the overlay immediately. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + overlay = BusyLoaderOverlay(target, text=text, opacity=opacity) + overlay.setGeometry(target.rect()) + filt = _OverlayEventFilter(target, overlay) + overlay._filter = filt # type: ignore[attr-defined] + target.installEventFilter(filt) + if start_loading: + overlay.show() + return overlay + + +# -------------------------- +# Launchable demo +# -------------------------- +class DemoWidget(BECWidget, QWidget): # pragma: no cover + def __init__(self, parent=None): + super().__init__( + parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…" + ) + + self._title = QLabel("Demo Content", self) + self._title.setAlignment(Qt.AlignCenter) + self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken) + lay = QVBoxLayout(self) + lay.addWidget(self._title) + waveform = Waveform(self) + waveform.plot([1, 2, 3, 4, 5]) + lay.addWidget(waveform, 1) + + QTimer.singleShot(5000, self._ready) + + def _ready(self): + self._title.setText("Ready ✓") + self.set_busy(False) + + +class DemoWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Busy Loader — BECWidget demo") + + left = DemoWidget() + right = DemoWidget() + + btn_on = QPushButton("Right → Loading") + btn_off = QPushButton("Right → Ready") + btn_text = QPushButton("Set custom text") + btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…")) + btn_off.clicked.connect(lambda: right.set_busy(False)) + btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…")) + + panel = QWidget() + prow = QVBoxLayout(panel) + prow.addWidget(btn_on) + prow.addWidget(btn_off) + prow.addWidget(btn_text) + prow.addStretch(1) + + central = QWidget() + row = QHBoxLayout(central) + row.setContentsMargins(12, 12, 12, 12) + row.setSpacing(12) + row.addWidget(left, 1) + row.addWidget(right, 1) + row.addWidget(panel, 0) + + self.setCentralWidget(central) + self.resize(900, 420) + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QApplication(sys.argv) + apply_theme("light") + w = DemoWindow() + w.show() + sys.exit(app.exec()) diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py new file mode 100644 index 000000000..2f9e859c2 --- /dev/null +++ b/tests/unit_tests/test_busy_loader.py @@ -0,0 +1,145 @@ +import pytest +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from bec_widgets import BECWidget + +from .client_mocks import mocked_client + + +class _TestBusyWidget(BECWidget, QWidget): + def __init__( + self, + parent=None, + *, + start_busy: bool = False, + busy_text: str = "Loading…", + theme_update: bool = False, + **kwargs, + ): + super().__init__( + parent=parent, + theme_update=theme_update, + start_busy=start_busy, + busy_text=busy_text, + **kwargs, + ) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(QLabel("content", self)) + + +@pytest.fixture +def widget_busy(qtbot, mocked_client): + w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…") + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +@pytest.fixture +def widget_idle(qtbot): + w = _TestBusyWidget(client=mocked_client, start_busy=False) + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "BECWidget should create a busy overlay in __init__" + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + qtbot.waitUntil(lambda: overlay.isVisible()) + + +def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): + overlay = getattr(widget_idle, "_busy_overlay", None) + assert overlay is None, "Overlay should be lazily created when idle" + + widget_idle.set_busy(True, "Fetching data…") + overlay = getattr(widget_idle, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + lbl = getattr(overlay, "_label") + assert lbl.text() == "Fetching data…" + + widget_idle.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + +def test_becwidget_overlay_tracks_resize(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + widget_busy.resize(480, 260) + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + +def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + frame = getattr(overlay, "_frame", None) + assert frame is not None, "Busy overlay must use an internal QFrame for visuals" + + # Insets are 10 px in the implementation + outer = overlay.rect() + # Ensure resizeEvent has run and frame geometry is updated + qtbot.waitUntil( + lambda: frame.geometry().width() == outer.width() - 20 + and frame.geometry().height() == outer.height() - 20 + ) + + inner = frame.geometry() + assert inner.left() == outer.left() + 10 + assert inner.top() == outer.top() + 10 + assert inner.right() == outer.right() - 10 + assert inner.bottom() == outer.bottom() - 10 + + # Style: dashed border + semi-transparent grey background + ss = frame.styleSheet() + assert "dashed" in ss + assert "border" in ss + assert "rgba(128, 128, 128, 110)" in ss + + +def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle): + overlay = getattr(widget_idle, "_busy_overlay", None) + assert overlay is None, "Overlay should be created on first text update" + + widget_idle.set_busy_text("Preparing…") + overlay = getattr(widget_idle, "_busy_overlay") + assert overlay is not None + assert overlay.isHidden() + + lbl = getattr(overlay, "_label") + assert lbl.text() == "Preparing…" + + +def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "Busy overlay should exist on a start_busy widget" + + # Initially visible because start_busy=True + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Switch OFF + widget_busy.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + # Switch ON again (with new text) + widget_busy.set_busy(True, "Back to work…") + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Same overlay instance reused (no duplication) + assert getattr(widget_busy, "_busy_overlay") is overlay + + # Label updated + lbl = getattr(overlay, "_label") + assert lbl.text() == "Back to work…" + + # Geometry follows parent after re-show + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) From 663c00f1a4d05d2742510584ec3e27cddaf8b122 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 2 Oct 2025 12:33:32 +0200 Subject: [PATCH 014/155] feat(actions): actions can be created with label text with beside or under alignment --- bec_widgets/utils/toolbars/actions.py | 69 +++++++++++++++++++++++++-- bec_widgets/utils/toolbars/toolbar.py | 25 +++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4278877b1..dbeb937c3 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -33,6 +33,32 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) +def create_action_with_text(toolbar_action, toolbar: QToolBar): + """ + Helper function to create a toolbar button with text beside or under the icon. + + Args: + toolbar_action(ToolBarAction): The toolbar action to create the button for. + toolbar(ModularToolBar): The toolbar to add the button to. + """ + + btn = QToolButton(parent=toolbar) + if getattr(toolbar_action, "label_text", None): + toolbar_action.action.setText(toolbar_action.label_text) + if getattr(toolbar_action, "tooltip", None): + toolbar_action.action.setToolTip(toolbar_action.tooltip) + btn.setToolTip(toolbar_action.tooltip) + + btn.setDefaultAction(toolbar_action.action) + btn.setAutoRaise(True) + if toolbar_action.text_position == "beside": + btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + else: + btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + btn.setText(toolbar_action.label_text) + toolbar.addWidget(btn) + + class NoCheckDelegate(QStyledItemDelegate): """To reduce space in combo boxes by removing the checkmark.""" @@ -114,15 +140,39 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): class QtIconAction(ToolBarAction): - def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): + def __init__( + self, + standard_icon, + tooltip=None, + checkable=False, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, + parent=None, + ): + """ + Action with a standard Qt icon for the toolbar. + + Args: + standard_icon: The standard icon from QStyle. + tooltip(str, optional): The tooltip for the action. Defaults to None. + checkable(bool, optional): Whether the action is checkable. Defaults to False. + label_text(str | None, optional): Optional label text to display beside or under the icon. + text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon. + parent(QWidget or None, optional): Parent widget for the underlying QAction. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.standard_icon = standard_icon self.icon = QApplication.style().standardIcon(standard_icon) self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) self.action.setCheckable(self.checkable) + self.label_text = label_text + self.text_position = text_position def add_to_toolbar(self, toolbar, target): - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): return self.icon @@ -139,6 +189,8 @@ class MaterialIconAction(ToolBarAction): filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. Defaults to None. + label_text (str | None, optional): Optional label text to display beside or under the icon. + text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon. parent (QWidget or None, optional): Parent widget for the underlying QAction. """ @@ -149,12 +201,20 @@ def __init__( checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, parent=None, ): + """ + MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon. + This enables per-action icon text without breaking the existing API. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.icon_name = icon_name self.filled = filled self.color = color + self.label_text = label_text + self.text_position = text_position # Generate the icon using the material_icon helper self.icon = material_icon( self.icon_name, @@ -178,7 +238,10 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar(QToolBar): The toolbar to add the action to. target(QWidget): The target widget for the action. """ - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): """ diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index c1b7b7f28..4b10fba84 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -6,7 +6,7 @@ from typing import DefaultDict, Literal from bec_lib.logger import bec_logger -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QAction, QColor from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget @@ -492,10 +492,33 @@ def __init__(self): self.toolbar.connect_bundle( "base", PerformanceConnection(self.toolbar.components, self) ) + self.toolbar.components.add_safe( + "text", + MaterialIconAction( + "text_fields", + tooltip="Test Text Action", + checkable=True, + label_text="text", + text_position="under", + ), + ) self.toolbar.show_bundles(["performance", "plot_export"]) self.toolbar.get_bundle("performance").add_action("save") + self.toolbar.get_bundle("performance").add_action("text") self.toolbar.refresh() + # Timer to disable and enable text button each 2s + self.timer = QTimer() + self.timer.timeout.connect(self.toggle_text_action) + self.timer.start(2000) + + def toggle_text_action(self): + text_action = self.toolbar.components.get_action("text") + if text_action.action.isEnabled(): + text_action.action.setEnabled(False) + else: + text_action.action.setEnabled(True) + def enable_fps_monitor(self, enabled: bool): """ Example method to enable or disable FPS monitoring. From b2505c6a5606733d82d397e6fff6b4048e83a182 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 4 Sep 2025 16:30:59 +0200 Subject: [PATCH 015/155] feat(main_app): main app with interactive app switcher --- bec_widgets/applications/main_app.py | 189 +++++++++ .../navigation_centre/__init__.py | 0 .../navigation_centre/reveal_animator.py | 114 ++++++ .../navigation_centre/side_bar.py | 357 +++++++++++++++++ .../navigation_centre/side_bar_components.py | 372 ++++++++++++++++++ bec_widgets/applications/views/__init__.py | 0 bec_widgets/applications/views/view.py | 262 ++++++++++++ .../advanced_dock_area/advanced_dock_area.py | 2 + tests/unit_tests/test_app_side_bar.py | 189 +++++++++ tests/unit_tests/test_main_app.py | 111 ++++++ tests/unit_tests/test_reveal_animator.py | 128 ++++++ 11 files changed, 1724 insertions(+) create mode 100644 bec_widgets/applications/main_app.py create mode 100644 bec_widgets/applications/navigation_centre/__init__.py create mode 100644 bec_widgets/applications/navigation_centre/reveal_animator.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar_components.py create mode 100644 bec_widgets/applications/views/__init__.py create mode 100644 bec_widgets/applications/views/view.py create mode 100644 tests/unit_tests/test_app_side_bar.py create mode 100644 tests/unit_tests/test_main_app.py create mode 100644 tests/unit_tests/test_reveal_animator.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py new file mode 100644 index 000000000..791f07519 --- /dev/null +++ b/bec_widgets/applications/main_app.py @@ -0,0 +1,189 @@ +from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget + +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow + + +class BECMainApp(BECMainWindow): + + def __init__( + self, + parent=None, + *args, + anim_duration: int = ANIMATION_DURATION, + show_examples: bool = False, + **kwargs, + ): + super().__init__(parent=parent, *args, **kwargs) + self._show_examples = bool(show_examples) + + # --- Compose central UI (sidebar + stack) + self.sidebar = SideBar(parent=self, anim_duration=anim_duration) + self.stack = QStackedWidget(self) + + container = QWidget(self) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.sidebar, 0) + layout.addWidget(self.stack, 1) + self.setCentralWidget(container) + + # Mapping for view switching + self._view_index: dict[str, int] = {} + self._current_view_id: str | None = None + self.sidebar.view_selected.connect(self._on_view_selected) + + self._add_views() + + def _add_views(self): + self.add_section("BEC Applications", "bec_apps") + self.ads = AdvancedDockArea(self) + + self.add_view( + icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" + ) + + if self._show_examples: + self.add_section("Examples", "examples") + waveform_view_popup = WaveformViewPopup( + parent=self, id="waveform_view_popup", title="Waveform Plot" + ) + waveform_view_stack = WaveformViewInline( + parent=self, id="waveform_view_stack", title="Waveform Plot" + ) + + self.add_view( + icon="show_chart", + title="Waveform With Popup", + id="waveform_popup", + widget=waveform_view_popup, + mini_text="Popup", + ) + self.add_view( + icon="show_chart", + title="Waveform InLine Stack", + id="waveform_stack", + widget=waveform_view_stack, + mini_text="Stack", + ) + + self.set_current("dock_area") + self.sidebar.add_dark_mode_item() + + # --- Public API ------------------------------------------------------ + def add_section(self, title: str, id: str, position: int | None = None): + return self.sidebar.add_section(title, id, position) + + def add_separator(self): + return self.sidebar.add_separator() + + def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None): + return self.sidebar.add_dark_mode_item(id=id, position=position) + + def add_view( + self, + *, + icon: str, + title: str, + id: str, + widget: QWidget, + mini_text: str | None = None, + position: int | None = None, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Register a view in the stack and create a matching nav item in the sidebar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the view/item. + widget(QWidget): The widget to add to the stack. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + + + """ + item = self.sidebar.add_item( + icon=icon, + title=title, + id=id, + mini_text=mini_text, + position=position, + from_top=from_top, + toggleable=toggleable, + exclusive=exclusive, + ) + # Wrap plain widgets into a ViewBase so enter/exit hooks are available + if isinstance(widget, ViewBase): + view_widget = widget + else: + view_widget = ViewBase(content=widget, parent=self, id=id, title=title) + + idx = self.stack.addWidget(view_widget) + self._view_index[id] = idx + return item + + def set_current(self, id: str) -> None: + if id in self._view_index: + self.sidebar.activate_item(id) + + # Internal: route sidebar selection to the stack + def _on_view_selected(self, vid: str) -> None: + # Determine current view + current_index = self.stack.currentIndex() + current_view = ( + self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None + ) + + # Ask current view whether we may leave + if current_view is not None and hasattr(current_view, "on_exit"): + may_leave = current_view.on_exit() + if may_leave is False: + # Veto: restore previous highlight without re-emitting selection + if self._current_view_id is not None: + self.sidebar.activate_item(self._current_view_id, emit_signal=False) + return + + # Proceed with switch + idx = self._view_index.get(vid) + if idx is None or not (0 <= idx < self.stack.count()): + return + self.stack.setCurrentIndex(idx) + new_view = self.stack.widget(idx) + self._current_view_id = vid + if hasattr(new_view, "on_enter"): + new_view.on_enter() + + +if __name__ == "__main__": # pragma: no cover + import argparse + import sys + + parser = argparse.ArgumentParser(description="BEC Main Application") + parser.add_argument( + "--examples", action="store_true", help="Show the Examples section with waveform demo views" + ) + # Let Qt consume the remaining args + args, qt_args = parser.parse_known_args(sys.argv[1:]) + + app = QApplication([sys.argv[0], *qt_args]) + apply_theme("dark") + w = BECMainApp(show_examples=args.examples) + w.show() + + sys.exit(app.exec()) diff --git a/bec_widgets/applications/navigation_centre/__init__.py b/bec_widgets/applications/navigation_centre/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/navigation_centre/reveal_animator.py b/bec_widgets/applications/navigation_centre/reveal_animator.py new file mode 100644 index 000000000..714f69da3 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/reveal_animator.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation +from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget + +ANIMATION_DURATION = 500 # ms + + +class RevealAnimator: + """Animate reveal/hide for a single widget using opacity + max W/H. + + This keeps the widget always visible to avoid jitter from setVisible(). + Collapsed state: opacity=0, maxW=0, maxH=0. + Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height(). + """ + + def __init__( + self, + widget: QWidget, + duration: int = ANIMATION_DURATION, + easing: QEasingCurve.Type = QEasingCurve.InOutCubic, + initially_revealed: bool = False, + *, + animate_opacity: bool = True, + animate_width: bool = True, + animate_height: bool = True, + ): + self.widget = widget + self.animate_opacity = animate_opacity + self.animate_width = animate_width + self.animate_height = animate_height + # Opacity effect + self.fx = QGraphicsOpacityEffect(widget) + widget.setGraphicsEffect(self.fx) + # Animations + self.opacity_anim = ( + QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None + ) + self.width_anim = ( + QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None + ) + self.height_anim = ( + QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None + ) + for anim in (self.opacity_anim, self.width_anim, self.height_anim): + if anim is not None: + anim.setDuration(duration) + anim.setEasingCurve(easing) + # Initialize to requested state + self.set_immediate(initially_revealed) + + def _natural_sizes(self) -> tuple[int, int]: + sh = self.widget.sizeHint() + w = max(sh.width(), 1) + h = max(sh.height(), 1) + return w, h + + def set_immediate(self, revealed: bool): + """ + Immediately set the widget to the target revealed/collapsed state. + + Args: + revealed(bool): True to reveal, False to collapse. + """ + w, h = self._natural_sizes() + if self.animate_opacity: + self.fx.setOpacity(1.0 if revealed else 0.0) + if self.animate_width: + self.widget.setMaximumWidth(w if revealed else 0) + if self.animate_height: + self.widget.setMaximumHeight(h if revealed else 0) + + def setup(self, reveal: bool): + """ + Prepare animations to transition to the target revealed/collapsed state. + + Args: + reveal(bool): True to reveal, False to collapse. + """ + # Prepare animations from current state to target + target_w, target_h = self._natural_sizes() + if self.opacity_anim is not None: + self.opacity_anim.setStartValue(self.fx.opacity()) + self.opacity_anim.setEndValue(1.0 if reveal else 0.0) + if self.width_anim is not None: + self.width_anim.setStartValue(self.widget.maximumWidth()) + self.width_anim.setEndValue(target_w if reveal else 0) + if self.height_anim is not None: + self.height_anim.setStartValue(self.widget.maximumHeight()) + self.height_anim.setEndValue(target_h if reveal else 0) + + def add_to_group(self, group: QParallelAnimationGroup): + """ + Add the prepared animations to the given animation group. + + Args: + group(QParallelAnimationGroup): The animation group to add to. + """ + if self.opacity_anim is not None: + group.addAnimation(self.opacity_anim) + if self.width_anim is not None: + group.addAnimation(self.width_anim) + if self.height_anim is not None: + group.addAnimation(self.height_anim) + + def animations(self): + """ + Get a list of all animations (non-None) for adding to a group. + """ + return [ + anim + for anim in (self.opacity_anim, self.height_anim, self.width_anim) + if anim is not None + ] diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py new file mode 100644 index 000000000..6354cafe2 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtWidgets +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal +from qtpy.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QScrollArea, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty, SafeSlot +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION +from bec_widgets.applications.navigation_centre.side_bar_components import ( + DarkModeNavItem, + NavigationItem, + SectionHeader, + SideBarSeparator, +) + + +class SideBar(QScrollArea): + view_selected = Signal(str) + toggled = Signal(bool) + + def __init__( + self, + parent=None, + title: str = "Control Panel", + collapsed_width: int = 56, + expanded_width: int = 250, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("SideBar") + + # private attributes + self._is_expanded = False + self._collapsed_width = collapsed_width + self._expanded_width = expanded_width + self._anim_duration = anim_duration + + # containers + self.components = {} + self._item_opts: dict[str, dict] = {} + + # Scroll area properties + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFrameShape(QtWidgets.QFrame.NoFrame) + self.setFixedWidth(self._collapsed_width) + + # Content widget holding buttons for switching views + self.content = QWidget(self) + self.content_layout = QVBoxLayout(self.content) + self.content_layout.setContentsMargins(0, 0, 0, 0) + self.content_layout.setSpacing(4) + self.setWidget(self.content) + + # Track active navigation item + self._active_id = None + + # Top row with title and toggle button + self.toggle_row = QWidget(self) + self.toggle_row_layout = QHBoxLayout(self.toggle_row) + + self.title_label = QLabel(title, self) + self.title_label.setObjectName("TopTitle") + self.title_label.setStyleSheet("font-weight: 600;") + self.title_fx = QGraphicsOpacityEffect(self.title_label) + self.title_label.setGraphicsEffect(self.title_fx) + self.title_fx.setOpacity(0.0) + self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift + + self.toggle = QToolButton(self) + self.toggle.setCheckable(False) + self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False)) + self.toggle.clicked.connect(self.on_expand) + + self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter) + self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter) + + # To push the content up always + self._bottom_spacer = QtWidgets.QSpacerItem( + 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + ) + + # Add core widgets to layout + self.content_layout.addWidget(self.toggle_row) + self.content_layout.addItem(self._bottom_spacer) + + # Animations + self.width_anim = QPropertyAnimation(self, b"bar_width") + self.width_anim.setDuration(self._anim_duration) + self.width_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.title_anim = QPropertyAnimation(self.title_fx, b"opacity") + self.title_anim.setDuration(self._anim_duration) + self.title_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.group = QParallelAnimationGroup(self) + self.group.addAnimation(self.width_anim) + self.group.addAnimation(self.title_anim) + self.group.finished.connect(self._on_anim_finished) + + app = QtWidgets.QApplication.instance() + if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"): + app.theme.theme_changed.connect(self._on_theme_changed) + + @SafeProperty(int) + def bar_width(self) -> int: + """ + Get the current width of the side bar. + + Returns: + int: The current width of the side bar. + """ + return self.width() + + @bar_width.setter + def bar_width(self, width: int): + """ + Set the width of the side bar. + + Args: + width(int): The new width of the side bar. + """ + self.setFixedWidth(width) + + @SafeProperty(bool) + def is_expanded(self) -> bool: + """ + Check if the side bar is expanded. + + Returns: + bool: True if the side bar is expanded, False otherwise. + """ + return self._is_expanded + + @SafeSlot() + @SafeSlot(bool) + def on_expand(self): + """ + Toggle the expansion state of the side bar. + """ + self._is_expanded = not self._is_expanded + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + + if self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter) + + self.group.stop() + # Setting limits for animations of the side bar + self.width_anim.setStartValue(self.width()) + self.width_anim.setEndValue( + self._expanded_width if self._is_expanded else self._collapsed_width + ) + self.title_anim.setStartValue(self.title_fx.opacity()) + self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0) + + # Setting limits for animations of the components + for comp in self.components.values(): + if hasattr(comp, "setup_animations"): + comp.setup_animations(self._is_expanded) + + self.group.start() + if self._is_expanded: + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + self.toggled.emit(self._is_expanded) + + @SafeSlot() + def _on_anim_finished(self): + if not self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter) + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + + @SafeSlot(str) + def _on_theme_changed(self, theme_name: str): + # Refresh toggle arrow icon so it picks up the new theme + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + # Refresh each component that supports it + for comp in self.components.values(): + if hasattr(comp, "refresh_theme"): + comp.refresh_theme() + else: + comp.style().unpolish(comp) + comp.style().polish(comp) + comp.update() + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader: + """ + Add a section header to the side bar. + + Args: + title(str): The title of the section. + id(str): Unique ID for the section. + position(int, optional): Position to insert the section header. + + Returns: + SectionHeader: The created section header. + + """ + header = SectionHeader(self, title, anim_duration=self._anim_duration) + position = position if position is not None else self.content_layout.count() - 1 + self.content_layout.insertWidget(position, header) + for anim in header.animations: + self.group.addAnimation(anim) + self.components[id] = header + return header + + def add_separator( + self, *, from_top: bool = True, position: int | None = None + ) -> SideBarSeparator: + """ + Add a separator line to the side bar. Separators are treated like regular + items; you can place multiple separators anywhere using `from_top` and `position`. + """ + line = SideBarSeparator(self) + line.setStyleSheet("margin:12px;") + self._insert_nav_item(line, from_top=from_top, position=position) + return line + + def add_item( + self, + icon: str, + title: str, + id: str, + mini_text: str | None = None, + position: int | None = None, + *, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Add a navigation item to the side bar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the nav item. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + """ + item = NavigationItem( + parent=self, + title=title, + icon_name=icon, + mini_text=mini_text, + toggleable=toggleable, + exclusive=exclusive, + anim_duration=self._anim_duration, + ) + self._insert_nav_item(item, from_top=from_top, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + # Connect activation to activation logic, passing id unchanged + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def activate_item(self, target_id: str, *, emit_signal: bool = True): + target = self.components.get(target_id) + if target is None: + return + # Non-toggleable acts like an action: do not change any toggled states + if hasattr(target, "toggleable") and not target.toggleable: + self._active_id = target_id + if emit_signal: + self.view_selected.emit(target_id) + return + + is_exclusive = getattr(target, "exclusive", True) + if is_exclusive: + # Radio-like behavior among exclusive items only + for comp_id, comp in self.components.items(): + if not isinstance(comp, NavigationItem): + continue + if comp is target: + comp.set_active(True) + else: + # Only untoggle other items that are also exclusive + if getattr(comp, "exclusive", True): + comp.set_active(False) + # Leave non-exclusive items as they are + else: + # Non-exclusive toggles independently + target.set_active(not target.is_active()) + + self._active_id = target_id + if emit_signal: + self.view_selected.emit(target_id) + + def add_dark_mode_item( + self, id: str = "dark_mode", position: int | None = None + ) -> DarkModeNavItem: + """ + Add a dark mode toggle item to the side bar. + + Args: + id(str): Unique ID for the dark mode item. + position(int, optional): Position to insert the dark mode item. + + Returns: + DarkModeNavItem: The created dark mode navigation item. + """ + item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration) + # compute bottom insertion point (same semantics as from_top=False) + self._insert_nav_item(item, from_top=False, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def _insert_nav_item( + self, item: QWidget, *, from_top: bool = True, position: int | None = None + ): + if from_top: + base_index = self.content_layout.indexOf(self._bottom_spacer) + pos = base_index if position is None else min(base_index, position) + else: + base = self.content_layout.indexOf(self._bottom_spacer) + 1 + pos = base if position is None else base + max(0, position) + self.content_layout.insertWidget(pos, item) diff --git a/bec_widgets/applications/navigation_centre/side_bar_components.py b/bec_widgets/applications/navigation_centre/side_bar_components.py new file mode 100644 index 000000000..67bb7666f --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar_components.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtCore +from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty +from bec_widgets.applications.navigation_centre.reveal_animator import ( + ANIMATION_DURATION, + RevealAnimator, +) + + +def get_on_primary(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("ON_PRIMARY") + return "#FFFFFF" + + +def get_fg(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("FG") + return "#FFFFFF" + + +class SideBarSeparator(QFrame): + """A horizontal line separator for use in SideBar.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SideBarSeparator") + self.setFrameShape(QFrame.NoFrame) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(2) + self.setProperty("variant", "separator") + + +class SectionHeader(QWidget): + """A section header with a label and a horizontal line below.""" + + def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION): + super().__init__(parent) + self.setObjectName("SectionHeader") + + self.lbl = QLabel(text, self) + self.lbl.setObjectName("SectionHeaderLabel") + self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False) + + self.line = SideBarSeparator(self) + + lay = QVBoxLayout(self) + # keep your margins/spacing preferences here if needed + lay.setContentsMargins(12, 0, 12, 0) + lay.setSpacing(6) + lay.addWidget(self.lbl) + lay.addWidget(self.line) + + self.animations = self.build_animations() + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return self._reveal.animations() + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self._reveal.setup(expanded) + + +class NavigationItem(QWidget): + """A nav tile with an icon + labels and an optional expandable body. + Provides animations for collapsed/expanded sidebar states via + build_animations()/setup_animations(), similar to SectionHeader. + """ + + activated = QtCore.Signal() + + def __init__( + self, + parent=None, + *, + title: str, + icon_name: str, + mini_text: str | None = None, + toggleable: bool = True, + exclusive: bool = True, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("NavigationItem") + + # Private attributes + self._title = title + self._icon_name = icon_name + self._mini_text = mini_text or title + self._toggleable = toggleable + self._toggled = False + self._exclusive = exclusive + + # Main Icon + self.icon_btn = QToolButton(self) + self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False)) + self.icon_btn.setAutoRaise(True) + self._icon_size_collapsed = QtCore.QSize(20, 20) + self._icon_size_expanded = QtCore.QSize(26, 26) + self.icon_btn.setIconSize(self._icon_size_collapsed) + # Remove QToolButton hover/pressed background/outline + self.icon_btn.setStyleSheet( + """ + QToolButton:hover { background: transparent; border: none; } + QToolButton:pressed { background: transparent; border: none; } + """ + ) + + # Mini label below icon + self.mini_lbl = QLabel(self._mini_text, self) + self.mini_lbl.setObjectName("NavMiniLabel") + self.mini_lbl.setAlignment(Qt.AlignCenter) + self.mini_lbl.setStyleSheet("font-size: 10px;") + self.reveal_mini_lbl = RevealAnimator( + widget=self.mini_lbl, + initially_revealed=True, + animate_width=False, + duration=anim_duration, + ) + + # Container for icon + mini label + self.mini_icon = QWidget(self) + mini_lay = QVBoxLayout(self.mini_icon) + mini_lay.setContentsMargins(0, 2, 0, 2) + mini_lay.setSpacing(2) + mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter) + mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter) + + # Title label + self.title_lbl = QLabel(self._title, self) + self.title_lbl.setObjectName("NavTitleLabel") + self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.title_lbl.setStyleSheet("font-size: 13px;") + self.reveal_title_lbl = RevealAnimator( + widget=self.title_lbl, + initially_revealed=False, + animate_height=False, + duration=anim_duration, + ) + self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift + + lay = QHBoxLayout(self) + lay.setContentsMargins(12, 2, 12, 2) + lay.setSpacing(6) + lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop) + lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter) + + self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize") + self.icon_size_anim.setDuration(anim_duration) + self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic) + + # Connect icon button to emit activation + self.icon_btn.clicked.connect(self._emit_activated) + self.setMouseTracking(True) + self.setAttribute(Qt.WA_StyledBackground, True) + + def is_active(self) -> bool: + """Return whether the item is currently active/selected.""" + return self.property("toggled") is True + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return ( + self.reveal_title_lbl.animations() + + self.reveal_mini_lbl.animations() + + [self.icon_size_anim] + ) + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self.reveal_mini_lbl.setup(not expanded) + self.reveal_title_lbl.setup(expanded) + self.icon_size_anim.setStartValue(self.icon_btn.iconSize()) + self.icon_size_anim.setEndValue( + self._icon_size_expanded if expanded else self._icon_size_collapsed + ) + + def set_visible(self, visible: bool): + """Set visibility of the title label.""" + self.title_lbl.setVisible(visible) + + def _emit_activated(self): + self.activated.emit() + + def set_active(self, active: bool): + """ + Set the active/selected state of the item. + + Args: + active(bool): True to set active, False to deactivate. + """ + self.setProperty("toggled", active) + self.toggled = active + # ensure style refresh + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mousePressEvent(self, event): + self.activated.emit() + super().mousePressEvent(event) + + @SafeProperty(bool) + def toggleable(self) -> bool: + """ + Whether the item is toggleable (like a button) or not (like an action). + + Returns: + bool: True if toggleable, False otherwise. + """ + return self._toggleable + + @toggleable.setter + def toggleable(self, value: bool): + """ + Set whether the item is toggleable (like a button) or not (like an action). + Args: + value(bool): True to make toggleable, False otherwise. + """ + self._toggleable = bool(value) + + @SafeProperty(bool) + def toggled(self) -> bool: + """ + Whether the item is currently toggled/selected. + + Returns: + bool: True if toggled, False otherwise. + """ + return self._toggled + + @toggled.setter + def toggled(self, value: bool): + """ + Set whether the item is currently toggled/selected. + + Args: + value(bool): True to set toggled, False to untoggle. + """ + self._toggled = value + if value: + new_icon = material_icon( + self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False + ) + else: + new_icon = material_icon( + self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False + ) + self.icon_btn.setIcon(new_icon) + # Re-polish so QSS applies correct colors to icon/labels + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + @SafeProperty(bool) + def exclusive(self) -> bool: + """ + Whether the item is exclusive in its toggle group. + + Returns: + bool: True if exclusive, False otherwise. + """ + return self._exclusive + + @exclusive.setter + def exclusive(self, value: bool): + """ + Set whether the item is exclusive in its toggle group. + + Args: + value(bool): True to make exclusive, False otherwise. + """ + self._exclusive = bool(value) + + def refresh_theme(self): + # Recompute icon/label colors according to current theme and state + # Trigger the toggled setter to rebuild the icon with the correct color + self.toggled = self._toggled + # Ensure QSS-driven text/icon colors refresh + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + +class DarkModeNavItem(NavigationItem): + """Bottom action item that toggles app theme and updates its icon/text.""" + + def __init__( + self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION + ): + super().__init__( + parent=parent, + title="Dark mode", + icon_name="dark_mode", + mini_text="Dark", + toggleable=False, # action-like, no selection highlight changes + exclusive=False, + anim_duration=anim_duration, + ) + self._id = id + self._sync_from_qapp_theme() + self.activated.connect(self.toggle_theme) + + def _qapp_dark_enabled(self) -> bool: + qapp = QApplication.instance() + return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark") + + def _sync_from_qapp_theme(self): + is_dark = self._qapp_dark_enabled() + # Update labels + self.title_lbl.setText("Light mode" if is_dark else "Dark mode") + self.mini_lbl.setText("Light" if is_dark else "Dark") + # Update icon + self.icon_btn.setIcon( + material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False) + ) + + def refresh_theme(self): + self._sync_from_qapp_theme() + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + def toggle_theme(self): + """Toggle application theme and update icon/text.""" + from bec_widgets.utils.colors import apply_theme + + is_dark = self._qapp_dark_enabled() + + apply_theme("light" if is_dark else "dark") + self._sync_from_qapp_theme() diff --git a/bec_widgets/applications/views/__init__.py b/bec_widgets/applications/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py new file mode 100644 index 000000000..3b98f7568 --- /dev/null +++ b/bec_widgets/applications/views/view.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from qtpy.QtCore import QEventLoop +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QStackedLayout, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.plots.waveform.waveform import Waveform + + +class ViewBase(QWidget): + """Wrapper for a content widget used inside the main app's stacked view. + + Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden. + + Args: + content (QWidget): The actual view widget to display. + parent (QWidget | None): Parent widget. + id (str | None): Optional view id, useful for debugging or introspection. + title (str | None): Optional human-readable title. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent) + self.content: QWidget | None = None + self.view_id = id + self.view_title = title + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + if content is not None: + self.set_content(content) + + def set_content(self, content: QWidget) -> None: + """Replace the current content widget with a new one.""" + if self.content is not None: + self.content.setParent(None) + self.content = content + self.layout().addWidget(content) + + @SafeSlot() + def on_enter(self) -> None: + """Called after the view becomes current/visible. + + Default implementation does nothing. Override in subclasses. + """ + pass + + @SafeSlot() + def on_exit(self) -> bool: + """Called before the view is switched away/hidden. + + Return True to allow switching, or False to veto. + Default implementation allows switching. + """ + return True + + +#################################################################################################### +# Example views for demonstration/testing purposes +#################################################################################################### + + +# --- Popup UI version --- +class WaveformViewPopup(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + self.waveform = Waveform(parent=self) + self.set_content(self.waveform) + + @SafeSlot() + def on_enter(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("Configure Waveform View") + + label = QLabel("Select device and signal for the waveform plot:", parent=dialog) + + # same as in the CurveRow used in waveform + self.device_edit = DeviceComboBox(parent=self) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + self.entry_edit = SignalComboBox(parent=self) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(label) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + v = QVBoxLayout(dialog) + v.addLayout(form) + v.addWidget(buttons) + + if dialog.exec_() == QDialog.Accepted: + self.waveform.plot( + y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText() + ) + + @SafeSlot() + def on_exit(self) -> bool: + ans = QMessageBox.question( + self, + "Switch and clear?", + "Do you want to switch views and clear the plot?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans == QMessageBox.Yes: + self.waveform.clear_all() + return True + return False + + +# --- Inline stacked UI version --- +class WaveformViewInline(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # Root layout for this view uses a stacked layout + self.stack = QStackedLayout() + container = QWidget(self) + container.setLayout(self.stack) + self.set_content(container) + + # --- Page 0: Settings page (inline form) + self.settings_page = QWidget() + sp_layout = QVBoxLayout(self.settings_page) + sp_layout.setContentsMargins(16, 16, 16, 16) + sp_layout.setSpacing(12) + + title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page) + self.device_edit = DeviceComboBox(parent=self.settings_page) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + + self.entry_edit = SignalComboBox(parent=self.settings_page) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(title) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", parent=self.settings_page) + cancel_btn = QPushButton("Cancel", parent=self.settings_page) + btn_row.addStretch(1) + btn_row.addWidget(cancel_btn) + btn_row.addWidget(ok_btn) + + sp_layout.addLayout(form) + sp_layout.addLayout(btn_row) + + # --- Page 1: Waveform page + self.waveform_page = QWidget() + wf_layout = QVBoxLayout(self.waveform_page) + wf_layout.setContentsMargins(0, 0, 0, 0) + self.waveform = Waveform(parent=self.waveform_page) + wf_layout.addWidget(self.waveform) + + # --- Page 2: Exit confirmation page (inline) + self.confirm_page = QWidget() + cp_layout = QVBoxLayout(self.confirm_page) + cp_layout.setContentsMargins(16, 16, 16, 16) + cp_layout.setSpacing(12) + qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page) + cp_buttons = QHBoxLayout() + no_btn = QPushButton("No", parent=self.confirm_page) + yes_btn = QPushButton("Yes", parent=self.confirm_page) + cp_buttons.addStretch(1) + cp_buttons.addWidget(no_btn) + cp_buttons.addWidget(yes_btn) + cp_layout.addWidget(qlabel) + cp_layout.addLayout(cp_buttons) + + # Add pages to the stack + self.stack.addWidget(self.settings_page) # index 0 + self.stack.addWidget(self.waveform_page) # index 1 + self.stack.addWidget(self.confirm_page) # index 2 + + # Wire settings buttons + ok_btn.clicked.connect(self._apply_settings_and_show_waveform) + cancel_btn.clicked.connect(self._show_waveform_without_changes) + + # Prepare result holder for the inline confirmation + self._exit_choice_yes = None + yes_btn.clicked.connect(lambda: self._exit_reply(True)) + no_btn.clicked.connect(lambda: self._exit_reply(False)) + + @SafeSlot() + def on_enter(self) -> None: + # Always start on the settings page when entering + self.stack.setCurrentIndex(0) + + @SafeSlot() + def on_exit(self) -> bool: + # Show inline confirmation page and synchronously wait for a choice + # -> trick to make the choice blocking, however popup would be cleaner solution + self._exit_choice_yes = None + self.stack.setCurrentIndex(2) + loop = QEventLoop() + self._exit_loop = loop + loop.exec_() + + if self._exit_choice_yes: + self.waveform.clear_all() + return True + # Revert to waveform view if user cancelled switching + self.stack.setCurrentIndex(1) + return False + + def _apply_settings_and_show_waveform(self): + dev = self.device_edit.currentText() + sig = self.entry_edit.currentText() + if dev and sig: + self.waveform.plot(y_name=dev, y_entry=sig) + self.stack.setCurrentIndex(1) + + def _show_waveform_without_changes(self): + # Just show waveform page without plotting + self.stack.setCurrentIndex(1) + + def _exit_reply(self, yes: bool): + self._exit_choice_yes = bool(yes) + if hasattr(self, "_exit_loop") and self._exit_loop.isRunning(): + self._exit_loop.quit() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 27a05299f..8bdee4a7e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -25,6 +25,7 @@ from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, @@ -923,6 +924,7 @@ def cleanup(self): import sys app = QApplication(sys.argv) + apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() ads = AdvancedDockArea(mode="developer", root_widget=True) diff --git a/tests/unit_tests/test_app_side_bar.py b/tests/unit_tests/test_app_side_bar.py new file mode 100644 index 000000000..830844ac7 --- /dev/null +++ b/tests/unit_tests/test_app_side_bar.py @@ -0,0 +1,189 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup, QSize + +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import ( + NavigationItem, + SectionHeader, +) + +ANIM_TEST_DURATION = 60 # ms + + +def _run(group: QParallelAnimationGroup, qtbot, duration=ANIM_TEST_DURATION): + group.start() + qtbot.wait(duration + 100) + + +@pytest.fixture +def header(qtbot): + w = SectionHeader(text="Group", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_section_header_initial_state_collapsed(header): + # RevealAnimator is initially collapsed for the label + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +def test_section_header_animates_reveal_and_hide(header, qtbot): + group = QParallelAnimationGroup() + for anim in header.build_animations(): + group.addAnimation(anim) + + # Expand + header.setup_animations(True) + _run(group, qtbot) + sh = header.lbl.sizeHint() + assert header.lbl.maximumWidth() >= sh.width() + assert header.lbl.maximumHeight() >= sh.height() + + # Collapse + header.setup_animations(False) + _run(group, qtbot) + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +@pytest.fixture +def nav(qtbot): + w = NavigationItem( + title="Counter", icon_name="widgets", mini_text="cnt", anim_duration=ANIM_TEST_DURATION + ) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_build_animations_contains(nav): + lst = nav.build_animations() + assert len(lst) == 5 + + +def test_setup_animations_changes_targets(nav, qtbot): + group = QParallelAnimationGroup() + for a in nav.build_animations(): + group.addAnimation(a) + + # collapsed -> expanded + nav.setup_animations(True) + _run(group, qtbot) + + sh_title = nav.title_lbl.sizeHint() + assert nav.title_lbl.maximumWidth() >= sh_title.width() + assert nav.mini_lbl.maximumHeight() == 0 + assert nav.icon_btn.iconSize() == QSize(26, 26) + + # expanded -> collapsed + nav.setup_animations(False) + _run(group, qtbot) + assert nav.title_lbl.maximumWidth() == 0 + sh_mini = nav.mini_lbl.sizeHint() + assert nav.mini_lbl.maximumHeight() >= sh_mini.height() + assert nav.icon_btn.iconSize() == QSize(20, 20) + + +def test_activation_signal_emits(nav, qtbot): + with qtbot.waitSignal(nav.activated, timeout=1000): + nav.icon_btn.click() + + +@pytest.fixture +def sidebar(qtbot): + sb = SideBar(title="Controls", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(sb) + qtbot.waitExposed(sb) + return sb + + +def test_add_section_and_separator(sidebar): + sec = sidebar.add_section("Group A", id="group_a") + assert sec is not None + sep = sidebar.add_separator() + assert sep is not None + assert sidebar.content_layout.indexOf(sep) != -1 + + +def test_add_item_top_and_bottom_positions(sidebar): + top_item = sidebar.add_item(icon="widgets", title="Top", id="top") + bottom_item = sidebar.add_item(icon="widgets", title="Bottom", id="bottom", from_top=False) + + i_spacer = sidebar.content_layout.indexOf(sidebar._bottom_spacer) + i_top = sidebar.content_layout.indexOf(top_item) + i_bottom = sidebar.content_layout.indexOf(bottom_item) + + assert i_top != -1 and i_bottom != -1 + assert i_bottom > i_spacer # bottom items go after the spacer + + +def test_selection_exclusive_and_nonexclusive(sidebar, qtbot): + a = sidebar.add_item(icon="widgets", title="A", id="a", exclusive=True) + b = sidebar.add_item(icon="widgets", title="B", id="b", exclusive=True) + c = sidebar.add_item(icon="widgets", title="C", id="c", exclusive=False) + + c._emit_activated() + qtbot.wait(10) + assert c.is_active() is True + + a._emit_activated() + qtbot.wait(10) + assert a.is_active() is True + assert b.is_active() is False + assert c.is_active() is True + + b._emit_activated() + qtbot.wait(200) + assert a.is_active() is False + assert b.is_active() is True + assert c.is_active() is True + + +def test_on_expand_configures_targets_and_shows_title(sidebar, qtbot): + # Start collapsed + assert sidebar._is_expanded is False + start_w = sidebar.width() + + sidebar.on_expand() + + assert sidebar.width_anim.startValue() == start_w + assert sidebar.width_anim.endValue() == sidebar._expanded_width + assert sidebar.title_anim.endValue() == 1.0 + + +def test__on_anim_finished_hides_on_collapse_and_resets_alignment(sidebar, qtbot): + # Add one item so set_visible is called on components too + item = sidebar.add_item(icon="widgets", title="Item", id="item") + + # Expand first + sidebar.on_expand() + qtbot.wait(ANIM_TEST_DURATION + 150) + assert sidebar._is_expanded is True + + # Now collapse + sidebar.on_expand() + # Wait for animation group to finish and _on_anim_finished to run + with qtbot.waitSignal(sidebar.group.finished, timeout=2000): + pass + + # Collapsed state + assert sidebar._is_expanded is False + + +def test_dark_mode_item_is_action(sidebar, qtbot, monkeypatch): + dm = sidebar.add_dark_mode_item() + + called = {"toggled": False} + + def fake_apply(theme): + called["toggled"] = True + + monkeypatch.setattr("bec_widgets.utils.colors.apply_theme", fake_apply, raising=False) + + before = dm.is_active() + dm._emit_activated() + qtbot.wait(200) + assert called["toggled"] is True + assert dm.is_active() == before diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py new file mode 100644 index 000000000..3d3a42f14 --- /dev/null +++ b/tests/unit_tests/test_main_app.py @@ -0,0 +1,111 @@ +import pytest +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.main_app import BECMainApp +from bec_widgets.applications.views.view import ViewBase + +from .client_mocks import mocked_client + +ANIM_TEST_DURATION = 60 # ms + + +@pytest.fixture +def viewbase(qtbot): + v = ViewBase(content=QWidget()) + qtbot.addWidget(v) + qtbot.waitExposed(v) + yield v + + +# Spy views for testing enter/exit hooks and veto logic +class SpyView(ViewBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.enter_calls = 0 + self.exit_calls = 0 + + def on_enter(self) -> None: + self.enter_calls += 1 + + def on_exit(self) -> bool: + self.exit_calls += 1 + return True + + +class SpyVetoView(SpyView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.allow_exit = False + + def on_exit(self) -> bool: + self.exit_calls += 1 + return bool(self.allow_exit) + + +@pytest.fixture +def app_with_spies(qtbot, mocked_client): + app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False) + qtbot.addWidget(app) + qtbot.waitExposed(app) + + app.add_section("Tests", id="tests") + + v1 = SpyView(id="v1", title="V1") + v2 = SpyView(id="v2", title="V2") + vv = SpyVetoView(id="vv", title="VV") + + app.add_view(icon="widgets", title="View 1", id="v1", widget=v1, mini_text="v1") + app.add_view(icon="widgets", title="View 2", id="v2", widget=v2, mini_text="v2") + app.add_view(icon="widgets", title="Veto View", id="vv", widget=vv, mini_text="vv") + + # Start from dock_area (default) to avoid extra enter/exit counts on spies + assert app.stack.currentIndex() == app._view_index["dock_area"] + return app, v1, v2, vv + + +def test_viewbase_initializes(viewbase): + assert viewbase.on_enter() is None + assert viewbase.on_exit() is True + + +def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot): + app, v1, v2, _ = app_with_spies + + app.set_current("v1") + qtbot.wait(10) + assert v1.enter_calls == 1 + + app.set_current("v2") + qtbot.wait(10) + assert v1.exit_calls == 1 + assert v2.enter_calls == 1 + + app.set_current("v1") + qtbot.wait(10) + assert v2.exit_calls == 1 + assert v1.enter_calls == 2 + + +def test_on_exit_veto_prevents_switch_until_allowed(app_with_spies, qtbot): + app, v1, v2, vv = app_with_spies + + # Move to veto view first + app.set_current("vv") + qtbot.wait(10) + assert vv.enter_calls == 1 + + # Attempt to leave veto view -> should veto + app.set_current("v1") + qtbot.wait(10) + assert vv.exit_calls == 1 + # Still on veto view because veto returned False + assert app.stack.currentIndex() == app._view_index["vv"] + + # Allow exit and try again + vv.allow_exit = True + app.set_current("v1") + qtbot.wait(10) + + # Now the switch should have happened, and v1 received on_enter + assert app.stack.currentIndex() == app._view_index["v1"] + assert v1.enter_calls >= 1 diff --git a/tests/unit_tests/test_reveal_animator.py b/tests/unit_tests/test_reveal_animator.py new file mode 100644 index 000000000..5704eb6ef --- /dev/null +++ b/tests/unit_tests/test_reveal_animator.py @@ -0,0 +1,128 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup +from qtpy.QtWidgets import QLabel + +from bec_widgets.applications.navigation_centre.reveal_animator import RevealAnimator + +ANIM_TEST_DURATION = 50 # ms + + +@pytest.fixture +def label(qtbot): + w = QLabel("Reveal Label") + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def _run_group(group: QParallelAnimationGroup, qtbot, duration_ms: int): + group.start() + qtbot.wait(duration_ms + 100) + + +def test_immediate_collapsed_then_revealed(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + # Initially collapsed + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + # Snap to revealed + anim.set_immediate(True) + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + +def test_reveal_then_collapse_with_animation(label, qtbot): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + # Collapse using the SAME group; do not re-add animations to avoid deletion + anim.setup(False) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + +@pytest.mark.parametrize( + "flags", + [ + dict(animate_opacity=False, animate_width=True, animate_height=True), + dict(animate_opacity=True, animate_width=False, animate_height=True), + dict(animate_opacity=True, animate_width=True, animate_height=False), + ], +) +def test_partial_flags_respectively_disable_properties(label, qtbot, flags): + # Establish initial state + label.setMaximumWidth(123) + label.setMaximumHeight(456) + + anim = RevealAnimator(label, duration=10, initially_revealed=False, **flags) + + # Record baseline values for disabled properties + baseline_opacity = anim.fx.opacity() + baseline_w = label.maximumWidth() + baseline_h = label.maximumHeight() + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + + if flags.get("animate_opacity", True): + assert anim.fx.opacity() == pytest.approx(1.0) + else: + # Opacity should remain unchanged + assert anim.fx.opacity() == pytest.approx(baseline_opacity) + + if flags.get("animate_width", True): + assert label.maximumWidth() == max(sh.width(), 1) + else: + assert label.maximumWidth() == baseline_w + + if flags.get("animate_height", True): + assert label.maximumHeight() == max(sh.height(), 1) + else: + assert label.maximumHeight() == baseline_h + + +def test_animations_list_and_order(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION) + lst = anim.animations() + # All should be present and in defined order: opacity, height, width + names = [a.propertyName() for a in lst] + assert names == [b"opacity", b"maximumHeight", b"maximumWidth"] + + +@pytest.mark.parametrize( + "flags,expected", + [ + (dict(animate_opacity=False), [b"maximumHeight", b"maximumWidth"]), + (dict(animate_width=False), [b"opacity", b"maximumHeight"]), + (dict(animate_height=False), [b"opacity", b"maximumWidth"]), + (dict(animate_opacity=False, animate_width=False, animate_height=True), [b"maximumHeight"]), + (dict(animate_opacity=False, animate_width=True, animate_height=False), [b"maximumWidth"]), + (dict(animate_opacity=True, animate_width=False, animate_height=False), [b"opacity"]), + (dict(animate_opacity=False, animate_width=False, animate_height=False), []), + ], +) +def test_animations_respects_flags(label, flags, expected): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, **flags) + names = [a.propertyName() for a in anim.animations()] + assert names == expected From ead1d38b492fa640530debb02404a504ec72a8c0 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 3 Oct 2025 13:19:07 +0200 Subject: [PATCH 016/155] fix(client): abort, reset, stop button removed from RPC access --- .../buttons/button_abort/button_abort.py | 2 +- .../buttons/button_reset/button_reset.py | 2 +- .../buttons/stop_button/stop_button.py | 2 +- .../test_user_interaction_e2e.py | 66 ------------------- 4 files changed, 3 insertions(+), 69 deletions(-) diff --git a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py index 7adc8d4ca..4a9585dc2 100644 --- a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py +++ b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py @@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "cancel" - RPC = True + RPC = False def __init__( self, diff --git a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py index caea1cc71..dc468a318 100644 --- a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py +++ b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py @@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "restart_alt" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) diff --git a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py index fdedf4f1f..218fa2e98 100644 --- a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py +++ b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py @@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "dangerous" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 98fb26c8b..2ae82b988 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -139,25 +139,6 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand ) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the AbortButton widget.""" - gui: BECGuiClient = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton) - dock: client.BECDock - widget: client.AbortButton - - # No rpc calls to check so far - - # Try detaching the dock - dock.detach() - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the BECProgressBar widget.""" @@ -637,53 +618,6 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton) - dock: client.BECDock - widget: client.StopButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton) - dock: client.BECDock - widget: client.ResumeButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton) - dock: client.BECDock - widget: client.ResetButton - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the TextBox widget""" From b66353bf6e86d7ab93388cc9b5b06ac0b384aa4b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 3 Oct 2025 17:57:03 +0200 Subject: [PATCH 017/155] fix(signal_label): dispatcher unsubscribed in the cleanup --- bec_widgets/widgets/utility/signal_label/signal_label.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/utility/signal_label/signal_label.py b/bec_widgets/widgets/utility/signal_label/signal_label.py index ebf80c9a2..f394db179 100644 --- a/bec_widgets/widgets/utility/signal_label/signal_label.py +++ b/bec_widgets/widgets/utility/signal_label/signal_label.py @@ -8,7 +8,6 @@ from bec_lib.device import Device, Signal from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import Qt from qtpy.QtCore import Signal as QSignal from qtpy.QtWidgets import ( QApplication, @@ -483,6 +482,11 @@ def _update_label(self): self._custom_label if self._custom_label else f"{self._default_label}:" ) + def cleanup(self): + self.disconnect_device() + self._device_obj = None + super().cleanup() + if __name__ == "__main__": app = QApplication(sys.argv) From dfe914bb7e8c9f283d389f6f8d776f6e8e169709 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 7 Oct 2025 13:44:59 +0200 Subject: [PATCH 018/155] feat(help-inspector): add help inspector widget --- bec_widgets/utils/bec_widget.py | 8 + bec_widgets/utils/help_inspector/__init__.py | 0 .../utils/help_inspector/help_inspector.py | 238 ++++++++++++++++++ tests/unit_tests/test_help_inspector.py | 81 ++++++ 4 files changed, 327 insertions(+) create mode 100644 bec_widgets/utils/help_inspector/__init__.py create mode 100644 bec_widgets/utils/help_inspector/help_inspector.py create mode 100644 tests/unit_tests/test_help_inspector.py diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index e4e72e525..02c4d6076 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -185,6 +185,14 @@ def _update_overlay_theme(self, theme: str): except Exception: logger.warning(f"Failed to apply theme {theme} to {self}") + def get_help_md(self) -> str: + """ + Method to override in subclasses to provide help text in markdown format. + + Returns: + str: The help text in markdown format. + """ + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) diff --git a/bec_widgets/utils/help_inspector/__init__.py b/bec_widgets/utils/help_inspector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py new file mode 100644 index 000000000..e9976945e --- /dev/null +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -0,0 +1,238 @@ +"""Module providing a simple help inspector tool for QtWidgets.""" + +from functools import partial +from typing import Callable +from uuid import uuid4 + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import AccentColors, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class HelpInspector(BECWidget, QtWidgets.QWidget): + """ + A help inspector widget that allows to inspect other widgets in the application. + Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget. + The method "get_help_md" is called on the widget which is added to the BECWidget base class. + It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown). + The inspector also allows to register custom callback that are called with the inspected widget + as argument. This may be useful in the future to hook up more callbacks with custom signals. + + Args: + parent (QWidget | None): The parent widget of the help inspector. + client: Optional client for BECWidget functionality. + size (tuple[int, int]): Optional size of the icon for the help inspector. + """ + + widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget + widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget + bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self._app = QtWidgets.QApplication.instance() + layout = QtWidgets.QHBoxLayout(self) # type: ignore + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._active = False + self._init_ui() + self._callbacks = {} + # Register the default callbacks + self._register_default_callbacks() + # Connect the button toggle signal + self._button.toggled.connect(self._toggle_mode) + + def _init_ui(self): + """Init the UI components.""" + colors: AccentColors = get_accent_colors() + self._button = QtWidgets.QToolButton(self.parent()) + self._button.setCheckable(True) + + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + self._button.setText("Help Inspect Tool") + self._button.setIcon(self._icon_unchecked()) + self._button.setToolTip("Click to enter Help Mode") + self.layout().addWidget(self._button) + + def apply_theme(self, theme: str) -> None: + colors = get_accent_colors() + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + if self._active: + self._button.setIcon(self._icon_checked()) + else: + self._button.setIcon(self._icon_unchecked()) + + @SafeSlot(bool) + def _toggle_mode(self, enabled: bool): + """ + Toggle the help inspection mode. + + Args: + enabled (bool): Whether to enable or disable the help inspection mode. + """ + if self._app is None: + self._app = QtWidgets.QApplication.instance() + self._active = enabled + if enabled: + self._app.installEventFilter(self) + self._button.setIcon(self._icon_checked()) + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor) + else: + self._app.removeEventFilter(self) + self._button.setIcon(self._icon_unchecked()) + self._button.setChecked(False) + QtWidgets.QApplication.restoreOverrideCursor() + + def eventFilter(self, obj, event): + """ + Filter events to capture Key_Escape event, and mouse clicks + if event filter is active. Any click event on a widget is suppressed, if + the Inspector is active, and the registered callbacks are called with + the clicked widget as argument. + + Args: + obj (QObject): The object that received the event. + event (QEvent): The event to filter. + """ + if ( + event.type() == QtCore.QEvent.KeyPress + and event.key() == QtCore.Qt.Key_Escape + and self._active + ): + self._toggle_mode(False) + return super().eventFilter(obj, event) + if self._active and event.type() == QtCore.QEvent.MouseButtonPress: + if event.button() == QtCore.Qt.LeftButton: + widget = self._app.widgetAt(event.globalPos()) + if widget: + if widget is self or self.isAncestorOf(widget): + self._toggle_mode(False) + return True + for cb in self._callbacks.values(): + try: + cb(widget) + except Exception as e: + print(f"Error occurred in callback {cb}: {e}") + return True + return super().eventFilter(obj, event) + + def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str: + """ + Register a callback to be called when a widget is inspected. + The callback should be callable with the following signature: + callback(widget: QWidget) -> None + + Args: + callback (Callable[[QWidget], None]): The callback function to register. + Returns: + str: A unique ID for the registered callback. + """ + cb_id = str(uuid4()) + self._callbacks[cb_id] = callback + return cb_id + + def unregister_callback(self, cb_id: str): + """Unregister a previously registered callback.""" + self._callbacks.pop(cb_id, None) + + def _register_default_callbacks(self): + """Default behavior: publish tooltip, docstring, bec_help""" + + def cb_doc(widget: QtWidgets.QWidget): + docstring = widget.__doc__ or "No documentation available." + self.widget_docstring.emit(docstring) + + def cb_help(widget: QtWidgets.QWidget): + tooltip = widget.toolTip() or "No tooltip available." + self.widget_tooltip.emit(tooltip) + + def cb_bec_help(widget: QtWidgets.QWidget): + help_text = None + if hasattr(widget, "get_help_md") and callable(widget.get_help_md): + try: + help_text = widget.get_help_md() + except Exception as e: + logger.debug(f"Error retrieving help text from {widget}: {e}") + if help_text is None: + help_text = widget.toolTip() or "No help available." + if not isinstance(help_text, str): + logger.error( + f"Help text from {widget.__class__} is not a string: {type(help_text)}" + ) + help_text = str(help_text) + self.bec_widget_help.emit(help_text) + + self.register_callback(cb_doc) + self.register_callback(cb_help) + self.register_callback(cb_bec_help) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + + from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QtWidgets.QApplication(sys.argv) + + main_window = QtWidgets.QMainWindow() + apply_theme("dark") + main_window.setWindowTitle("Help Inspector Test") + + central_widget = QtWidgets.QWidget() + main_layout = QtWidgets.QVBoxLayout(central_widget) + dark_mode_button = DarkModeButton(parent=main_window) + main_layout.addWidget(dark_mode_button) + + help_inspector = HelpInspector() + main_layout.addWidget(help_inspector) + + test_button = QtWidgets.QPushButton("Test Button") + test_button.setToolTip("This is a test button.") + test_line_edit = QtWidgets.QLineEdit() + test_line_edit.setToolTip("This is a test line edit.") + test_label = QtWidgets.QLabel("Test Label") + test_label.setToolTip("") + box = PositionerBox() + + layout_1 = QtWidgets.QHBoxLayout() + layout_1.addWidget(test_button) + layout_1.addWidget(test_line_edit) + layout_1.addWidget(test_label) + layout_1.addWidget(box) + main_layout.addLayout(layout_1) + + doc_label = QtWidgets.QLabel("Docstring will appear here.") + tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.") + bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.") + main_layout.addWidget(doc_label) + main_layout.addWidget(tool_tip_label) + main_layout.addWidget(bec_help_label) + + help_inspector.widget_tooltip.connect(tool_tip_label.setText) + help_inspector.widget_docstring.connect(doc_label.setText) + help_inspector.bec_widget_help.connect(bec_help_label.setText) + + main_window.setCentralWidget(central_widget) + main_window.resize(400, 200) + main_window.show() + sys.exit(app.exec()) diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py new file mode 100644 index 000000000..75cd738b6 --- /dev/null +++ b/tests/unit_tests/test_help_inspector.py @@ -0,0 +1,81 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import pytest +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton + +from .client_mocks import mocked_client + + +@pytest.fixture +def help_inspector(qtbot, mocked_client): + widget = HelpInspector(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def abort_button(qtbot): + widget = AbortButton() + widget.setToolTip("This is an abort button.") + + def get_help_md(): + return "This is **markdown** help text for the abort button." + + widget.get_help_md = get_help_md # type: ignore + + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + yield widget + + +def test_help_inspector_button(help_inspector): + """Test the HelpInspector widget.""" + assert not help_inspector._active + help_inspector._button.click() + assert help_inspector._active + assert help_inspector._button.isChecked() + cursor = QtWidgets.QApplication.overrideCursor() + assert cursor is not None + assert cursor.shape() == QtCore.Qt.CursorShape.WhatsThisCursor + help_inspector._button.click() + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_register_callback(help_inspector): + """Test registering a callback in the HelpInspector widget.""" + + assert len(help_inspector._callbacks) == 3 # default callbacks + + def my_callback(widget): + pass + + cb_id = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 4 + assert help_inspector._callbacks[cb_id] == my_callback + + cb_id2 = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 5 + assert help_inspector._callbacks[cb_id2] == my_callback + + help_inspector.unregister_callback(cb_id) + assert len(help_inspector._callbacks) == 4 + + help_inspector.unregister_callback(cb_id2) + assert len(help_inspector._callbacks) == 3 + + +def test_help_inspector_escape_key(qtbot, help_inspector): + """Test that pressing the Escape key deactivates the HelpInspector.""" + help_inspector._button.click() + assert help_inspector._active + qtbot.keyClick(help_inspector, QtCore.Qt.Key.Key_Escape) + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None From e8c062a48fbf05624aace327780d84c1ef945cb9 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 22 Aug 2025 07:55:33 +0200 Subject: [PATCH 019/155] feat(dm-view): initial device manager view added --- bec_widgets/applications/main_app.py | 12 + .../views/device_manager_view/__init__.py | 0 .../device_manager_view.py | 687 +++++++++++ .../device_manager_widget.py | 119 ++ bec_widgets/utils/bec_widget.py | 1 + bec_widgets/utils/expandable_frame.py | 36 +- bec_widgets/utils/forms_from_types/forms.py | 28 +- bec_widgets/utils/forms_from_types/items.py | 42 +- .../utils/help_inspector/help_inspector.py | 27 +- .../utils/list_of_expandable_frames.py | 133 +++ .../advanced_dock_area/states/user/test.ini | 234 ++++ .../device_manager/components/__init__.py | 4 + .../device_manager/components/_util.py | 53 + .../available_device_resources/__init__.py | 3 + .../available_device_group.py | 230 ++++ .../available_device_group_ui.py | 56 + .../available_device_resources.py | 128 +++ .../available_device_resources_ui.py | 135 +++ .../device_resource_backend.py | 140 +++ .../device_manager/components/constants.py | 72 ++ .../components/device_table_view.py | 1022 ++++++++++++----- .../components/dm_config_view.py | 100 ++ .../components/dm_docstring_view.py | 133 +++ .../components/dm_ophyd_test.py | 418 +++++++ .../services/device_browser/device_browser.py | 59 +- .../services/device_browser/device_browser.ui | 173 ++- .../device_item/config_communicator.py | 9 +- .../device_item/device_config_dialog.py | 255 ++-- .../device_item/device_config_form.py | 27 +- .../device_browser/device_item/device_item.py | 7 +- tests/unit_tests/test_device_browser.py | 34 +- .../test_device_config_form_dialog.py | 6 +- tests/unit_tests/test_device_input_base.py | 6 +- .../test_device_manager_components.py | 869 ++++++++++++++ tests/unit_tests/test_device_manager_view.py | 224 ++++ tests/unit_tests/test_help_inspector.py | 51 + 36 files changed, 4988 insertions(+), 545 deletions(-) create mode 100644 bec_widgets/applications/views/device_manager_view/__init__.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_view.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_widget.py create mode 100644 bec_widgets/utils/list_of_expandable_frames.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini create mode 100644 bec_widgets/widgets/control/device_manager/components/_util.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py create mode 100644 bec_widgets/widgets/control/device_manager/components/constants.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_config_view.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py create mode 100644 tests/unit_tests/test_device_manager_components.py create mode 100644 tests/unit_tests/test_device_manager_view.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 791f07519..da210c973 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,6 +3,9 @@ from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, +) from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea @@ -44,10 +47,18 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) + self.device_manager = DeviceManagerWidget(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" ) + self.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=self.device_manager, + mini_text="DM", + ) if self._show_examples: self.add_section("Examples", "examples") @@ -184,6 +195,7 @@ def _on_view_selected(self, vid: str) -> None: app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") w = BECMainApp(show_examples=args.examples) + w.resize(1920, 1200) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/__init__.py b/bec_widgets/applications/views/device_manager_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py new file mode 100644 index 000000000..9acdb5a38 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -0,0 +1,687 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import List, Literal + +import PySide6QtAds as QtAds +import yaml +from bec_lib import config_helper +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.file_utils import DeviceConfigWriter +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_qthemes import apply_theme +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QThreadPool, QTimer +from qtpy.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, +) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( + AvailableDeviceResources, +) +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, +) +from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( + PresetClassDeviceConfigDialog, +) + +logger = bec_logger.logger + +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +class ConfigChoiceDialog(QDialog): + REPLACE = 1 + ADD = 2 + CANCEL = 0 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Load Config") + layout = QVBoxLayout(self) + + label = QLabel("Do you want to replace the current config or add to it?") + label.setWordWrap(True) + layout.addWidget(label) + + # Buttons: equal size, stacked vertically + self.replace_btn = QPushButton("Replace") + self.add_btn = QPushButton("Add") + self.cancel_btn = QPushButton("Cancel") + btn_layout = QHBoxLayout() + for btn in (self.replace_btn, self.add_btn, self.cancel_btn): + btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + btn_layout.addWidget(btn) + layout.addLayout(btn_layout) + + # Connect signals to explicit slots + self.replace_btn.clicked.connect(self.accept_replace) + self.add_btn.clicked.connect(self.accept_add) + self.cancel_btn.clicked.connect(self.reject_cancel) + + self._result = self.CANCEL + + def accept_replace(self): + self._result = self.REPLACE + self.accept() + + def accept_add(self): + self._result = self.ADD + self.accept() + + def reject_cancel(self): + self._result = self.CANCEL + self.reject() + + def result(self): + return self._result + + +AVAILABLE_RESOURCE_IS_READY = False + + +class DeviceManagerView(BECWidget, QWidget): + + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, client=None, *args, **kwargs) + + self._config_helper = config_helper.ConfigHelper(self.client.connector) + self._shared_selection = SharedSelectionSignal() + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Device Table View widget + self.device_table_view = DeviceTableView( + self, shared_selection_signal=self._shared_selection + ) + self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self) + self.device_table_view_dock.setWidget(self.device_table_view) + + # Device Config View widget + self.dm_config_view = DMConfigView(self) + self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self) + self.dm_config_view_dock.setWidget(self.dm_config_view) + + # Docstring View + self.dm_docs_view = DocstringView(self) + self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self) + self.dm_docs_view_dock.setWidget(self.dm_docs_view) + + # Ophyd Test view + self.ophyd_test_view = DMOphydTest(self) + self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) + self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) + + # Help Inspector + widget = QWidget(self) + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.help_inspector = HelpInspector(self) + layout.addWidget(self.help_inspector) + text_box = QTextEdit(self) + text_box.setReadOnly(False) + text_box.setPlaceholderText("Help text will appear here...") + layout.addWidget(text_box) + self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) + self.help_inspector_dock.setWidget(widget) + + # Register callback + self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) + + # Error Logs View + self.error_logs_view = QTextEdit(self) + self.error_logs_view.setReadOnly(True) + self.error_logs_view.setPlaceholderText("Error logs will appear here...") + self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self) + self.error_logs_dock.setWidget(self.error_logs_view) + self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown) + + # Arrange widgets within the QtAds dock manager + # Central widget area + self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) + # Right area - should be pushed into view if something is active + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, + self.ophyd_test_dock_view, + self.central_dock_area, + ) + # create bottom area (2-arg -> area) + self.bottom_dock_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock + ) + + # YAML view left of docstrings (docks relative to bottom area) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area + ) + + # Error/help area right of docstrings (dock relative to bottom area) + area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, + self.help_inspector_dock, + self.bottom_dock_area, + ) + self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) + + for dock in self.dock_manager.dockWidgets(): + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + # Apply stretch after the layout is done + self.set_default_view([2, 8, 2], [7, 3]) + + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.device_validated, + (self.device_table_view.update_device_validation,), + ), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.change_device_configs,), + ), + ]: + for slot in slots: + signal.connect(slot) + + # Once available resource is ready, add it to the view again + if AVAILABLE_RESOURCE_IS_READY: + # Available Resources Widget + self.available_devices = AvailableDeviceResources( + self, shared_selection_signal=self._shared_selection + ) + self.available_devices_dock = QtAds.CDockWidget( + self.dock_manager, "Available Devices", self + ) + self.available_devices_dock.setWidget(self.available_devices) + # Connect slots for available reosource + for signal, slots in [ + ( + self.available_devices.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.device_table_view.device_configs_changed, + (self.available_devices.mark_devices_used,), + ), + ( + self.available_devices.add_selected_devices, + (self.device_table_view.add_device_configs,), + ), + ( + self.available_devices.del_selected_devices, + (self.device_table_view.remove_device_configs,), + ), + ]: + for slot in slots: + signal.connect(slot) + + # Add toolbar + self._add_toolbar() + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Load from disk + load = MaterialIconAction( + text_position="under", + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + save_to_disk = MaterialIconAction( + text_position="under", + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", + ) + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") + + # Add load config from redis + load_redis = MaterialIconAction( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from Redis", + label_text="Get Current Config", + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + text_position="under", + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in Redis", + label_text="Update Config", + ) + update_config_redis.action.setEnabled(False) + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + # Table actions + + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config", + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction( + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", + ) + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", + ) + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._rerun_validation_action) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + # IO actions + def _coming_soon(self): + return QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, caption="Save Config File", dir=start_dir + ) + return file_path + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ + dialog = ConfigChoiceDialog(self) + if dialog.exec(): + if dialog.result() == ConfigChoiceDialog.REPLACE: + self.device_table_view.set_device_config(config) + elif dialog.result() == ConfigChoiceDialog.ADD: + self.device_table_view.add_device_configs(config) + + # TODO would we ever like to add the current config to an existing composition + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + reply = _yes_no_question( + self, + "Load currently active config", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + else: + return + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """Action to push the current composition to Redis""" + reply = _yes_no_question( + self, + "Push composition to Redis", + "Do you really want to replace the active configuration in the BEC server with the current composition? ", + ) + if reply != QMessageBox.StandardButton.Yes: + return + if self.device_table_view.table.contains_invalid_devices(): + return QMessageBox.warning( + self, "Validation has errors!", "Please resolve before proceeding." + ) + if self.ophyd_test_view.validation_running(): + return QMessageBox.warning( + self, "Validation has not completed.", "Please wait for the validation to finish." + ) + self._push_composition_to_redis() + + def _push_composition_to_redis(self): + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, "set") + threadpool.start(comm) + + @SafeSlot() + def _save_to_disk_action(self): + """Action for the 'save_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path = self._get_file_path(config_path, "save_file") + if file_path: + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # Table actions + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + # TODO Bespoke Form to add a new device + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + dialog = PresetClassDeviceConfigDialog(parent=self) + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict) + def _add_to_table_from_dialog(self, data): + self.device_table_view.add_device_configs([data]) + + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + self.device_table_view.remove_selected_rows() + + @SafeSlot() + @SafeSlot(bool) + def _rerun_validation_action(self, connect: bool = True): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + configs = self.device_table_view.table.selected_configs() + self.ophyd_test_view.change_device_configs(configs, True, connect) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view( + self, horizontal_weights: list, vertical_weights: list + ): # TODO separate logic for all ads based widgets + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Orientation.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Orientation.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch( + self, *, horizontal=None, vertical=None + ): # TODO separate logic for all ads based widgets + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": + import sys + from copy import deepcopy + + from bec_lib.bec_yaml_loader import yaml_load + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerView() + l.addWidget(device_manager_view) + # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + # cfg = yaml_load(config_path) + # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + + # # config = device_manager_view.client.device_manager._get_redis_device_config() + # device_manager_view.device_table_view.set_device_config(cfg) + w.show() + w.setWindowTitle("Device Manager View") + w.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py new file mode 100644 index 000000000..8c24a9b95 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -0,0 +1,119 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +import os + +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + self.setLayout(self.stacked_layout) + + # Add device manager view + self.device_manager_view = DeviceManagerView() + self.stacked_layout.addWidget(self.device_manager_view) + + # Add overlay widget + self._overlay_widget = QtWidgets.QWidget(self) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_overlay(self): + self._overlay_widget.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + # Load current config + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + # Load config from disk + self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File") + icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False) + self.button_load_config_from_file.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_config_from_file) + self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked) + self._overlay_widget.setVisible(True) + + def _load_config_from_file_clicked(self): + """Handle click on 'Load Config From File' button.""" + start_dir = os.path.expanduser("~") + file_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + if file_path: + self._load_config_from_file(file_path) + + def _load_config_from_file(self, file_path: str): + try: + config = yaml_load(file_path) + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + config_list = [] + for name, cfg in config.items(): + config_list.append(cfg) + config_list[-1]["name"] = name + self.device_manager_view.device_table_view.set_device_config(config_list) + # self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_view.device_table_view.set_device_config(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + from bec_widgets.utils.colors import apply_theme + + apply_theme("light") + + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + device_manager = DeviceManagerWidget() + # config = device_manager.client.device_manager._get_redis_device_config() + # device_manager.device_table_view.set_device_config(config) + layout.addWidget(device_manager) + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + dark_mode_button = DarkModeButton() + layout.addWidget(dark_mode_button) + widget.show() + device_manager.setWindowTitle("Device Manager View") + device_manager.resize(1600, 1200) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 02c4d6076..ef397d03c 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -192,6 +192,7 @@ def get_help_md(self) -> str: Returns: str: The help text in markdown format. """ + return "" @SafeSlot() @SafeSlot(str) diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e0..08a4d95f4 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -1,7 +1,7 @@ from __future__ import annotations from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import QSize, Signal from qtpy.QtWidgets import ( QApplication, QFrame, @@ -19,7 +19,8 @@ class ExpandableGroupFrame(QFrame): - + broadcast_size_hint = Signal(QSize) + imminent_deletion = Signal() expansion_state_changed = Signal() EXPANDED_ICON_NAME: str = "collapse_all" @@ -31,10 +32,11 @@ def __init__( super().__init__(parent=parent) self._expanded = expanded - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) + self._title_text = f"{title}" + self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setContentsMargins(5, 0, 0, 0) self.setLayout(self._layout) self._create_title_layout(title, icon) @@ -49,21 +51,27 @@ def __init__( def _create_title_layout(self, title: str, icon: str): self._title_layout = QHBoxLayout() self._layout.addLayout(self._title_layout) + self._internal_title_layout = QHBoxLayout() + self._title_layout.addLayout(self._internal_title_layout) - self._title = ClickableLabel(f"{title}") + self._title = ClickableLabel() + self._set_title_text(self._title_text) self._title_icon = ClickableLabel() - self._title_layout.addWidget(self._title_icon) - self._title_layout.addWidget(self._title) + self._internal_title_layout.addWidget(self._title_icon) + self._internal_title_layout.addWidget(self._title) self.icon_name = icon self._title.clicked.connect(self.switch_expanded_state) self._title_icon.clicked.connect(self.switch_expanded_state) - self._title_layout.addStretch(1) + self._internal_title_layout.addStretch(1) self._expansion_button = QToolButton() self._update_expansion_icon() self._title_layout.addWidget(self._expansion_button, stretch=1) + def get_title_layout(self) -> QHBoxLayout: + return self._internal_title_layout + def set_layout(self, layout: QLayout) -> None: self._contents.setLayout(layout) self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore @@ -112,6 +120,18 @@ def _set_title_icon(self, icon_name: str): else: self._title_icon.setVisible(False) + @SafeProperty(str) + def title_text(self): # type: ignore + return self._title_text + + @title_text.setter + def title_text(self, title_text: str): + self._title_text = title_text + self._set_title_text(self._title_text) + + def _set_title_text(self, title_text: str): + self._title.setText(title_text) + # Application example if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index eb5e31e61..9797af2e0 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from types import NoneType +from types import GenericAlias, NoneType, UnionType from typing import NamedTuple from bec_lib.logger import bec_logger @@ -11,7 +11,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.items import ( DynamicFormItem, @@ -215,6 +215,9 @@ def __init__( self._connect_to_theme_change() + @SafeSlot() + def clear(self): ... + def set_pretty_display_theme(self, theme: str = "dark"): if self._pretty_display: self.setStyleSheet(styles.pretty_display_theme(theme)) @@ -279,3 +282,24 @@ def validate_form(self, *_) -> bool: self.form_data_cleared.emit(None) self.validity_proc.emit(False) return False + + +class PydanticModelFormItem(DynamicFormItem): + def __init__( + self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] + ) -> None: + self._data_model = model + + super().__init__(parent=parent, spec=spec) + self._main_widget.form_data_updated.connect(self._value_changed) + + def _add_main_widget(self) -> None: + + self._main_widget = PydanticModelForm(data_model=self._data_model) + self._layout.addWidget(self._main_widget) + + def getValue(self): + return self._main_widget.get_form_data() + + def setValue(self, value: dict): + self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 04acf7ff3..a0b8e1f7a 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import typing from abc import abstractmethod from decimal import Decimal @@ -14,8 +15,10 @@ NamedTuple, Optional, OrderedDict, + Protocol, TypeVar, get_args, + runtime_checkable, ) from bec_lib.logger import bec_logger @@ -170,9 +173,10 @@ def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() + # Sadly, QWidget and ABC are not compatible assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore - self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) if not spec.pretty_display: if clearable_required(spec.info): self._add_clear_button() @@ -187,6 +191,7 @@ def setValue(self, value): ... @abstractmethod def _add_main_widget(self) -> None: + self._main_widget: QWidget """Add the main data entry widget to self._main_widget and appply any constraints from the field info""" @@ -404,7 +409,7 @@ def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None def sizeHint(self): default = super().sizeHint() - return QSize(default.width(), QFontMetrics(self.font()).height() * 6) + return QSize(default.width(), QFontMetrics(self.font()).height() * 4) def _add_main_widget(self) -> None: self._main_widget = QListWidget() @@ -454,10 +459,17 @@ def _add_data_item(self, val=None): self._add_list_item(val) self._repop(self._data) + def _item_height(self): + return int(QFontMetrics(self.font()).height() * 1.5) + def _add_list_item(self, val): item = QListWidgetItem(self._main_widget) item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) item_widget = self._types.widget(parent=self) + item_widget.setMinimumHeight(self._item_height()) + self._main_widget.setGridSize(QSize(0, self._item_height())) + if (layout := item_widget.layout()) is not None: + layout.setContentsMargins(0, 0, 0, 0) WidgetIO.set_value(item_widget, val) self._main_widget.setItemWidget(item, item_widget) self._main_widget.addItem(item) @@ -494,14 +506,11 @@ def setValue(self, value: Iterable): self._data = list(value) self._repop(self._data) - def _line_height(self): - return QFontMetrics(self._main_widget.font()).height() - def set_max_height_in_lines(self, lines: int): outer_inc = 1 if self._spec.pretty_display else 3 - self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines)) - self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1)) - self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc)) + self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines)) + self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1)) + self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc)) def scale_to_data(self, *_): self.set_max_height_in_lines(self._main_widget.count() + 1) @@ -584,6 +593,16 @@ def _add_main_widget(self) -> None: WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] +@runtime_checkable +class _ItemTypeFn(Protocol): + def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... + + +WidgetTypeRegistry = OrderedDict[ + str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] +] + + def _is_string_literal(t: type): return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str} @@ -637,7 +656,10 @@ def widget_from_type( widget_types = widget_types or DEFAULT_WIDGET_TYPES for predicate, widget_type in widget_types.values(): if predicate(spec): - return widget_type + if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): + return widget_type + return widget_type(spec) + logger.warning( f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." ) diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py index e9976945e..9a73cd34c 100644 --- a/bec_widgets/utils/help_inspector/help_inspector.py +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -11,6 +11,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import AccentColors, get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.widget_io import WidgetHierarchy logger = bec_logger.logger @@ -100,7 +101,7 @@ def _toggle_mode(self, enabled: bool): self._button.setChecked(False) QtWidgets.QApplication.restoreOverrideCursor() - def eventFilter(self, obj, event): + def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool: """ Filter events to capture Key_Escape event, and mouse clicks if event filter is active. Any click event on a widget is suppressed, if @@ -111,25 +112,33 @@ def eventFilter(self, obj, event): obj (QObject): The object that received the event. event (QEvent): The event to filter. """ - if ( - event.type() == QtCore.QEvent.KeyPress - and event.key() == QtCore.Qt.Key_Escape - and self._active - ): + # If not active, return immediately + if not self._active: + return super().eventFilter(obj, event) + # If active, handle escape key + if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape: self._toggle_mode(False) return super().eventFilter(obj, event) - if self._active and event.type() == QtCore.QEvent.MouseButtonPress: + # If active, and left mouse button pressed, handle click + if event.type() == QtCore.QEvent.MouseButtonPress: if event.button() == QtCore.Qt.LeftButton: widget = self._app.widgetAt(event.globalPos()) + if widget is None: + return super().eventFilter(obj, event) + # Get BECWidget ancestor + # TODO check what happens if the HELP Inspector itself is embedded in another BECWidget + # I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one + if not isinstance(widget, BECWidget): + widget = WidgetHierarchy._get_becwidget_ancestor(widget) if widget: - if widget is self or self.isAncestorOf(widget): + if widget is self: self._toggle_mode(False) return True for cb in self._callbacks.values(): try: cb(widget) except Exception as e: - print(f"Error occurred in callback {cb}: {e}") + logger.error(f"Error occurred in callback {cb}: {e}") return True return super().eventFilter(obj, event) diff --git a/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py new file mode 100644 index 000000000..7ad85a713 --- /dev/null +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -0,0 +1,133 @@ +import re +from functools import partial +from re import Pattern +from typing import Generic, Iterable, NamedTuple, TypeVar + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import ( + SORT_KEY_ROLE, + SortableQListWidgetItem, +) + +logger = bec_logger.logger + + +_EF = TypeVar("_EF", bound=ExpandableGroupFrame) + + +class ListOfExpandableFrames(QListWidget, Generic[_EF]): + def __init__( + self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame + ) -> None: + super().__init__(parent) + _Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF))) + self.item_tuple = _Items + self._item_class = item_class + self._item_dict: dict[str, _Items] = {} + + def __contains__(self, id: str): + return id in self._item_dict + + def clear(self) -> None: + self._item_dict = {} + return super().clear() + + def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]: + """Adds the specified type of widget as an item. args and kwargs are passed to the constructor. + + Args: + id (str): the key under which to store the list item in the internal dict + + Returns: + The widget created in the addition process + """ + + def _remove_item(item: QListWidgetItem): + self.takeItem(self.row(item)) + del self._item_dict[id] + self.sortItems() + + def _updatesize(item: QListWidgetItem, item_widget: _EF): + item_widget.adjustSize() + item.setSizeHint(QSize(item_widget.width(), item_widget.height())) + + item = SortableQListWidgetItem(self) + item.setData(SORT_KEY_ROLE, id) # used for sorting + + item_widget = self._item_class(*args, **kwargs) + item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget)) + item_widget.imminent_deletion.connect(partial(_remove_item, item)) + item_widget.broadcast_size_hint.connect(item.setSizeHint) + + self.addItem(item) + self.setItemWidget(item, item_widget) + self._item_dict[id] = self.item_tuple(item, item_widget) + + item.setSizeHint(item_widget.sizeHint()) + return (item, item_widget) + + def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder): + items = [self.takeItem(0) for i in range(self.count())] + items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder)) + + for it in items: + self.addItem(it) + # reattach its custom widget + widget = self.itemWidget(it) + if widget: + self.setItemWidget(it, widget) + + def item_widget_pairs(self): + return self._item_dict.values() + + def widgets(self): + return (i.widget for i in self._item_dict.values()) + + def get_item_widget(self, id: str): + if (item := self._item_dict.get(id)) is None: + return None + return item + + def set_hidden_pattern(self, pattern: Pattern): + self.hide_all() + self._set_hidden(filter(pattern.search, self._item_dict.keys()), False) + + def set_hidden(self, ids: Iterable[str]): + self._set_hidden(ids, True) + + def _set_hidden(self, ids: Iterable[str], hidden: bool): + for id in ids: + if (_item := self._item_dict.get(id)) is not None: + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + else: + logger.warning( + f"List {self.__qualname__} does not have an item with ID {id} to hide!" + ) + self.sortItems() + + def hide_all(self): + self.set_hidden_state_on_all(True) + + def unhide_all(self): + self.set_hidden_state_on_all(False) + + def set_hidden_state_on_all(self, hidden: bool): + for _item in self._item_dict.values(): + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + self.sortItems() + + @SafeSlot(str) + def update_filter(self, value: str): + if value == "": + return self.unhide_all() + try: + self.set_hidden_pattern(re.compile(value, re.IGNORECASE)) + except Exception: + self.unhide_all() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini new file mode 100644 index 000000000..6188162c0 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini @@ -0,0 +1,234 @@ +[BECMainWindowNoRPC.AdvancedDockArea] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 29 2075 974) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_workspace=false +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mode=developer +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle=Advanced Dock Area + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +compact_view=false +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +expand_popup=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 1252 897) +hide_toolbar=false +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +label=BEC Queue +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +tooltip=BEC Queue status +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +auto_range_x=true +auto_range_y=true +baseSize=@Size(0 0) +color_palette=plasma +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +curve_json=[] +enable_fps_monitor=false +enable_popups=true +enable_side_panel=false +enable_toolbar=true +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 798 897) +inner_axes=true +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +legend_label_size=9 +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_aspect_ratio=false +max_dataset_size_mb=10 +maximumSize=@Size(16777215 16777215) +minimal_crosshair_precision=3 +minimumSize=@Size(0 0) +mouseTracking=false +outer_axes=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +skip_large_dataset_check=false +skip_large_dataset_warning=false +statusTip= +styleSheet= +tabletTracking=false +title= +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= +x_entry= +x_grid=false +x_label= +x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +x_log=false +x_mode=auto +y_grid=false +y_label= +y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +y_log=false + +[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +dark_mode_enabled=false +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 40 40) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(40 40) +minimumSize=@Size(40 40) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] +autoRepeat=true +checkable=false +checked=false +enabled=true +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" +iconText=Dock settings +iconVisibleInMenu=false +menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) +priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) +shortcut= +shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) +shortcutVisibleInContextMenu=false +statusTip= +text=Dock settings +toolTip=Dock settings +visible=true +whatsThis= + +[Perspectives] +1\Name=test +1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +size=1 + +[mainWindow] +DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) +State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) + +[manifest] +widgets\1\closable=true +widgets\1\floatable=true +widgets\1\movable=true +widgets\1\object_name=BECQueue +widgets\1\widget_class=BECQueue +widgets\2\closable=true +widgets\2\floatable=true +widgets\2\movable=true +widgets\2\object_name=PositionerBox +widgets\2\widget_class=PositionerBox +widgets\3\closable=true +widgets\3\floatable=true +widgets\3\movable=true +widgets\3\object_name=Waveform +widgets\3\widget_class=Waveform +widgets\size=3 diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index e69de29bb..bec612eef 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -0,0 +1,4 @@ +from .device_table_view import DeviceTableView +from .dm_config_view import DMConfigView +from .dm_docstring_view import DocstringView +from .dm_ophyd_test import DMOphydTest diff --git a/bec_widgets/widgets/control/device_manager/components/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py new file mode 100644 index 000000000..fb1f69935 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -0,0 +1,53 @@ +import json +from typing import Any, Callable, Generator, Iterable, TypeVar + +from bec_lib.utils.json import ExtendedEncoder +from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore +from qtpy.QtWidgets import QListWidgetItem + +from bec_widgets.widgets.control.device_manager.components.constants import ( + MIME_DEVICE_CONFIG, + SORT_KEY_ROLE, +) + +_T = TypeVar("_T") +_RT = TypeVar("_RT") + + +def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]: + for v in vals: + try: + yield fn(v) + except BaseException: + pass + + +def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData: + """Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG""" + mime_obj = QMimeData() + byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj + + +class SortableQListWidgetItem(QListWidgetItem): + """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with + custom widgets and this item.""" + + def __gt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() > other_key.lower() + + def __lt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() < other_key.lower() + + +class SharedSelectionSignal(QObject): + proc = Signal(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py new file mode 100644 index 000000000..83d4d4d0f --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py @@ -0,0 +1,3 @@ +from .available_device_resources import AvailableDeviceResources + +__all__ = ["AvailableDeviceResources"] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py new file mode 100644 index 000000000..96759d7b2 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -0,0 +1,230 @@ +from textwrap import dedent +from typing import NamedTuple +from uuid import uuid4 + +from bec_qthemes import material_icon +from qtpy.QtCore import QItemSelection, QSize, Signal +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import ( + Ui_AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +def _warning_string(spec: HashableDevice): + name_warning = ( + "Device defined with multiple names! Please check:\n " + "\n ".join(spec.names) + if len(spec.names) > 1 + else "" + ) + source_warning = ( + "Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files) + if len(spec._source_files) > 1 + else "" + ) + return f"{name_warning}{source_warning}" + + +class _DeviceEntryWidget(QFrame): + + def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self._device_spec = device_spec + self.included: bool = False + + self.setFrameStyle(0) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(2, 2, 2, 2) + self.setLayout(self._layout) + + self.setup_title_layout(device_spec) + self.check_and_display_warning() + + self.setToolTip(self._rich_text()) + + def _rich_text(self): + return dedent( + f""" +

{self._device_spec.name}:

+ + + + + +
description: {self._device_spec.description}
config: {self._device_spec.deviceConfig}
enabled: {self._device_spec.enabled}
read only: {self._device_spec.readOnly}
+ """ + ) + + def setup_title_layout(self, device_spec: HashableDevice): + self._title_layout = QHBoxLayout() + self._title_layout.setContentsMargins(0, 0, 0, 0) + self._title_container = QWidget(parent=self) + self._title_container.setLayout(self._title_layout) + + self._warning_label = QLabel() + self._title_layout.addWidget(self._warning_label) + + self.title = QLabel(device_spec.name) + self.title.setToolTip(device_spec.name) + self.title.setStyleSheet(self.title_style("#FF0000")) + self._title_layout.addWidget(self.title) + + self._title_layout.addStretch(1) + self._layout.addWidget(self._title_container) + + def check_and_display_warning(self): + if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1: + self._warning_label.setText("") + self._warning_label.setToolTip("") + else: + self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00")) + self._warning_label.setToolTip(_warning_string(self._device_spec)) + + @property + def device_hash(self): + return hash(self._device_spec) + + def title_style(self, color: str) -> str: + return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}" + + def setTitle(self, text: str): + self.title.setText(text) + + def set_included(self, included: bool): + self.included = included + self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000")) + + +class _DeviceEntry(NamedTuple): + list_item: QListWidgetItem + widget: _DeviceEntryWidget + + +class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): + + selected_devices = Signal(list) + + def __init__( + self, + parent=None, + name: str = "TagGroupTitle", + data: set[HashableDevice] = set(), + shared_selection_signal=SharedSelectionSignal(), + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self.title_text = name # type: ignore + self._mime_data = [] + self._devices: dict[str, _DeviceEntry] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + self.setMinimumSize(self.device_list.sizeHint()) + self._update_num_included() + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + device_dump = device.model_dump(exclude_defaults=True) + item.setData(CONFIG_DATA_ROLE, device_dump) + self._mime_data.append(device_dump) + widget = _DeviceEntryWidget(device, self) + item.setSizeHint(QSize(widget.width(), widget.height())) + self.device_list.setItemWidget(item, widget) + self.device_list.addItem(item) + self._devices[device.name] = _DeviceEntry(item, widget) + + def create_mime_data(self): + return self._mime_data + + def reset_devices_state(self): + for dev in self._devices.values(): + dev.widget.set_included(False) + self._update_num_included() + + def set_item_state(self, /, device_hash: int, included: bool): + for dev in self._devices.values(): + if dev.widget.device_hash == device_hash: + dev.widget.set_included(included) + self._update_num_included() + + def _update_num_included(self): + n_included = sum(int(dev.widget.included) for dev in self._devices.values()) + if n_included == 0: + color = "#FF0000" + elif n_included == len(self._devices): + color = "#00FF00" + else: + color = "#FFAA00" + self.n_included.setText(f"{n_included} / {len(self._devices)}") + self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}") + + def sizeHint(self) -> QSize: + if not getattr(self, "device_list", None) or not self.expanded: + return super().sizeHint() + return QSize( + max(150, self.device_list.viewport().width()), + self.device_list.sizeHintForRow(0) * self.device_list.count() + 50, + ) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + config = [dev.as_normal_device().model_dump() for dev in self.get_selection()] + self.selected_devices.emit(config) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_list.clearSelection() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self.sizeHint().height()) + self.setMaximumHeight(self.sizeHint().height()) + + def get_selection(self) -> set[HashableDevice]: + selection = self.device_list.selectedItems() + widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection) + return set(w._device_spec for w in widgets) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.title_text}" + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceGroup(name="Tag group 1") + for item in [ + HashableDevice( + **{ + "name": f"test_device_{i}", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, + } + ) + for i in range(5) + ]: + widget._add_item(item) + widget._update_num_included() + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py new file mode 100644 index 000000000..bea0a1c34 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout + +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + +if TYPE_CHECKING: + from .available_device_group import AvailableDeviceGroup + + +class _DeviceListWiget(QListWidget): + + def _item_iter(self): + return (self.item(i) for i in range(self.count())) + + def all_configs(self): + return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()] + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items) + + +class Ui_AvailableDeviceGroup(object): + def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"): + if not AvailableDeviceGroup.objectName(): + AvailableDeviceGroup.setObjectName("AvailableDeviceGroup") + AvailableDeviceGroup.setMinimumWidth(150) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + AvailableDeviceGroup.set_layout(self.verticalLayout) + + title_layout = AvailableDeviceGroup.get_title_layout() + + self.n_included = QLabel(AvailableDeviceGroup, text="...") + self.n_included.setObjectName("n_included") + title_layout.addWidget(self.n_included) + + self.device_list = _DeviceListWiget(AvailableDeviceGroup) + self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_list.setObjectName("device_list") + self.device_list.setFrameStyle(0) + self.device_list.setDragEnabled(True) + self.device_list.setAcceptDrops(False) + self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.verticalLayout.addWidget(self.device_list) + AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box) + QMetaObject.connectSlotsByName(AvailableDeviceGroup) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 000000000..93e810156 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,128 @@ +from random import randint +from typing import Any, Iterable +from uuid import uuid4 + +from qtpy.QtCore import QItemSelection, Signal # type: ignore +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import ( + SharedSelectionSignal, + yield_only_passing, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( + Ui_availableDeviceResources, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + get_backend, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + + selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected + add_selected_devices = Signal(list) + del_selected_devices = Signal(list) + + def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self._backend = get_backend() + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_groups_list.selectionModel().selectionChanged.connect( + self._on_selection_changed + ) + self.grouping_selector.addItem("deviceTags") + self.grouping_selector.addItems(self._backend.allowed_sort_keys) + self._grouping_selection_changed("deviceTags") + self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed) + self.search_box.textChanged.connect(self.device_groups_list.update_filter) + + self.tb_add_selected.action.triggered.connect(self._add_selected_action) + self.tb_del_selected.action.triggered.connect(self._del_selected_action) + + def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): + self.device_groups_list.clear() + for device_group, devices in device_groups.items(): + self._add_device_group(device_group, devices) + if self.grouping_selector.currentText == "deviceTags": + self._add_device_group("Untagged devices", self._backend.untagged_devices) + self.device_groups_list.sortItems() + + def _add_device_group(self, device_group: str, devices: set[HashableDevice]): + item, widget = self.device_groups_list.add_item( + device_group, + self.device_groups_list, + device_group, + devices, + shared_selection_signal=self._shared_selection_signal, + expanded=False, + ) + item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) + # Re-emit the selected items from a subgroup - all other selections should be disabled anyway + widget.selected_devices.connect(self.selected_devices) + + def resizeEvent(self, event): + super().resizeEvent(event) + for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): + list_item.setSizeHint(device_group_widget.sizeHint()) + + @SafeSlot() + def _add_selected_action(self): + self.add_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot() + def _del_selected_action(self): + self.del_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups()) + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_groups_list.clearSelection() + + def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for device in devices: + for device_group in self.device_groups_list.widgets(): + device_group.set_item_state(hash(device), included) + + @SafeSlot(list) + def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool): + """Set the display color of individual devices and update the group display of numbers + included. Accepts a list of dicts with the complete config as used in + bec_lib.atlas_models.Device.""" + self._set_devices_state( + yield_only_passing(HashableDevice.model_validate, config_list), used + ) + + @SafeSlot(str) + def _grouping_selection_changed(self, sort_key: str): + self.search_box.setText("") + if sort_key == "deviceTags": + device_groups = self._backend.tag_groups + else: + device_groups = self._backend.group_by_key(sort_key) + self.refresh_full_list(device_groups) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceResources() + widget._set_devices_state( + list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True + ) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py new file mode 100644 index 000000000..05701864a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import itertools + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QGridLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, +) + +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( + AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup: + return super().itemWidget(item) # type: ignore + + def any_selected_devices(self): + return self.selected_individual_devices() or self.selected_devices_from_groups() + + def selected_individual_devices(self): + for widget in (self.itemWidget(self.item(i)) for i in range(self.count())): + if (selected := widget.get_selection()) != set(): + return [dev.as_normal_device().model_dump() for dev in selected] + return [] + + def selected_devices_from_groups(self): + selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows()) + widgets = (self.itemWidget(item) for item in selected_items) + return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets)) + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs( + itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items) + ) + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + + self._add_toolbar() + + # Main area with search and filter using a grid layout + self.search_layout = QVBoxLayout() + self.grid_layout = QGridLayout() + + self.grouping_selector = QComboBox() + self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_group = QLabel("Group by:") + lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_group, 0, 0) + self.grid_layout.addWidget(self.grouping_selector, 0, 1) + + self.search_box = QLineEdit() + self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_filter = QLabel("Filter:") + lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_filter, 1, 0) + self.grid_layout.addWidget(self.search_box, 1, 1) + + self.grid_layout.setColumnStretch(0, 0) + self.grid_layout.setColumnStretch(1, 1) + + self.search_layout.addLayout(self.grid_layout) + self.verticalLayout.addLayout(self.search_layout) + + self.device_groups_list = _ListOfDeviceGroups( + availableDeviceResources, AvailableDeviceGroup + ) + self.device_groups_list.setObjectName("device_groups_list") + self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setMovement(QListView.Movement.Static) + self.device_groups_list.setSpacing(4) + self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) + self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) + self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_groups_list.setDragEnabled(True) + self.device_groups_list.setAcceptDrops(False) + self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction) + self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + availableDeviceResources.setMinimumWidth(250) + availableDeviceResources.resize(250, availableDeviceResources.height()) + + self.verticalLayout.addWidget(self.device_groups_list) + + QMetaObject.connectSlotsByName(availableDeviceResources) + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + self.tb_add_selected = MaterialIconAction( + icon_name="add_box", parent=self, tooltip="Add selected devices to composition" + ) + self.toolbar.components.add_safe("add_selected", self.tb_add_selected) + io_bundle.add_action("add_selected") + + self.tb_del_selected = MaterialIconAction( + icon_name="chips", parent=self, tooltip="Remove selected devices from composition" + ) + self.toolbar.components.add_safe("del_selected", self.tb_del_selected) + io_bundle.add_action("del_selected") + + self.verticalLayout.addWidget(self.toolbar) + self.toolbar.add_bundle(io_bundle) + self.toolbar.show_bundles(["IO"]) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py new file mode 100644 index 000000000..145d21109 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import operator +import os +from enum import Enum, auto +from functools import partial, reduce +from glob import glob +from pathlib import Path +from typing import Protocol + +import bec_lib +from bec_lib.atlas_models import HashableDevice, HashableDeviceSet +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed + +logger = bec_logger.logger + +# use the last n recovery files +_N_RECOVERY_FILES = 3 +_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.." + + +def get_backend() -> DeviceResourceBackend: + return _ConfigFileBackend() + + +class HashModel(str, Enum): + DEFAULT = auto() + DEFAULT_DEVICECONFIG = auto() + DEFAULT_EPICS = auto() + + +class DeviceResourceBackend(Protocol): + @property + def tag_groups(self) -> dict[str, set[HashableDevice]]: + """A dictionary of all availble devices separated by tag groups. The same device may + appear more than once (in different groups).""" + ... + + @property + def all_devices(self) -> set[HashableDevice]: + """A set of all availble devices. The same device may not appear more than once.""" + ... + + @property + def untagged_devices(self) -> set[HashableDevice]: + """A set of all untagged devices. The same device may not appear more than once.""" + ... + + @property + def allowed_sort_keys(self) -> set[str]: + """A set of all fields which you may group devices by""" + ... + + def tags(self) -> set[str]: + """Returns a set of all the tags in all available devices.""" + ... + + def tag_group(self, tag: str) -> set[HashableDevice]: + """Returns a set of the devices in the tag group with the given key.""" + ... + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + """Return a dict of all devices, organised by the specified key, which must be one of + the string keys in the Device model.""" + ... + + +def _devices_from_file(file: str, include_source: bool = True): + data = yaml_load(file, process_includes=False) + return HashableDeviceSet( + HashableDevice.model_validate( + dev | {"name": name, "source_files": {file} if include_source else set()} + ) + for name, dev in data.items() + ) + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files() + if plugins_installed() == 1: + self._raw_device_set.update( + self._get_configs_from_plugin_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + ) + self._device_groups = self._get_tag_groups() + + def _get_config_from_backup_files(self): + dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" + files = sorted(glob("*.yaml", root_dir=dir)) + last_n_files = files[-_N_RECOVERY_FILES:] + return reduce( + operator.or_, + map( + partial(_devices_from_file, include_source=False), + (str(dir / f) for f in last_n_files), + ), + set(), + ) + + def _get_configs_from_plugin_files(self, dir: Path): + files = glob("*.yaml", root_dir=dir, recursive=True) + return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set()) + + def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: + return { + tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set)) + for tag in self.tags() + } + + @property + def tag_groups(self): + return self._device_groups + + @property + def all_devices(self): + return self._raw_device_set + + @property + def untagged_devices(self): + return {d for d in self._raw_device_set if d.deviceTags == set()} + + @property + def allowed_sort_keys(self) -> set[str]: + return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} + + def tags(self) -> set[str]: + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set()) + + def tag_group(self, tag: str) -> set[HashableDevice]: + return self.tag_groups[tag] + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + if key not in self.allowed_sort_keys: + raise ValueError(f"Cannot group available devices by model key {key}") + group_names: set[str] = {getattr(item, key) for item in self._raw_device_set} + return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names} diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py new file mode 100644 index 000000000..b3f720511 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -0,0 +1,72 @@ +from typing import Final + +# Denotes a MIME type for JSON-encoded list of device config dictionaries +MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config" + +# Custom user roles +SORT_KEY_ROLE: Final[int] = 117 +CONFIG_DATA_ROLE: Final[int] = 118 + +# TODO 882 keep in sync with headers in device_table_view.py +HEADERS_HELP_MD: dict[str, str] = { + "status": "\n".join( + [ + "## Status", + "The current status of the device. Can be one of the following values: ", + "### **LOADED** \n The device with the specified configuration is loaded in the current config.", + "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.", + "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.", + "### **VALID** \n The device config is valid, but the connection has not yet been validated.", + "### **INVALID** \n The device config is invalid and can not be loaded to the current config.", + ] + ), + "name": "\n".join(["## Name ", "The name of the device."]), + "deviceClass": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "readoutPriority": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "deviceTags": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "enabled": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "readOnly": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "onFailure": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "softwareTrigger": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "description": "\n".join(["## Description", "A short description of the device."]), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index b541916b6..886b02c78 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -4,114 +4,327 @@ import copy import json +import textwrap +from contextlib import contextmanager +from functools import partial +from typing import TYPE_CHECKING, Any, Iterable, List, Literal +from uuid import uuid4 +from bec_lib.atlas_models import Device from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox from thefuzz import fuzz +from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.constants import ( + HEADERS_HELP_MD, + MIME_DEVICE_CONFIG, +) +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus + +if TYPE_CHECKING: # pragma: no cover + from bec_qthemes._theme import AccentColors logger = bec_logger.logger +_DeviceCfgIter = Iterable[dict[str, Any]] + # Threshold for fuzzy matching, careful with adjusting this. 80 seems good FUZZY_SEARCH_THRESHOLD = 80 +# +USER_CHECK_DATA_ROLE = 101 + class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - @staticmethod - def dict_to_str(d: dict) -> str: - """Convert a dictionary to a formatted string.""" - return json.dumps(d, indent=4) - - def helpEvent(self, event, view, option, index): + def helpEvent( + self, + event: QtCore.QEvent, + view: QtWidgets.QAbstractItemView, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.ToolTip: + if event.type() != QtCore.QEvent.Type.ToolTip: return super().helpEvent(event, view, option, index) model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) - row_dict = model.sourceModel().row_data(model_index) - row_dict.pop("description", None) - QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view) + row_dict = model.sourceModel().get_row_data(model_index) + description = row_dict.get("description", "") + QtWidgets.QToolTip.showText(event.globalPos(), description, view) return True -class CenterCheckBoxDelegate(DictToolTipDelegate): - """Custom checkbox delegate to center checkboxes in table cells.""" +class CustomDisplayDelegate(DictToolTipDelegate): + _paint_test_role = Qt.ItemDataRole.DisplayRole - def __init__(self, parent=None): - super().__init__(parent) - colors = get_accent_colors() - self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default - ) - self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default - ) + def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: + return "" - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - self._icon_checked.setColor(colors.default) - self._icon_unchecked.setColor(colors.default) - - def paint(self, painter, option, index): - value = index.model().data(index, QtCore.Qt.CheckStateRole) - if value is None: - super().paint(painter, option, index) - return - - # Choose icon based on state - pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked + def _test_custom_paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ): + v = index.model().data(index, self._paint_test_role) + return (v is not None), v - # Draw icon centered - rect = option.rect - pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Any, + ): ... - def editorEvent(self, event, model, option, index): - if event.type() != QtCore.QEvent.MouseButtonRelease: - return False - current = model.data(index, QtCore.Qt.CheckStateRole) - new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked - return model.setData(index, new_state, QtCore.Qt.CheckStateRole) + def paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ) -> None: + (check, value) = self._test_custom_paint(painter, option, index) + if not check: + return super().paint(painter, option, index) + super().paint(painter, option, index) + painter.save() + self._do_custom_paint(painter, option, index, value) + painter.restore() -class WrappingTextDelegate(DictToolTipDelegate): - """Custom delegate for wrapping text in table cells.""" +class WrappingTextDelegate(CustomDisplayDelegate): + """A lightweight delegate that wraps text without expensive size recalculation.""" - def paint(self, painter, option, index): - text = index.model().data(index, QtCore.Qt.DisplayRole) + def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6): + super().__init__(parent) + self._parent = parent + self.max_width = max_width + self.margin = margin + self._cache = {} # cache text metrics for performance + self._wrapping_text_columns = None + + @property + def wrapping_text_columns(self) -> List[int]: + # Compute once, cache for later + if self._wrapping_text_columns is None: + self._wrapping_text_columns = [] + view = self._parent + proxy: DeviceFilterProxyModel = self._parent.model() + for col in range(proxy.columnCount()): + delegate = view.itemDelegateForColumn(col) + if isinstance(delegate, WrappingTextDelegate): + self._wrapping_text_columns.append(col) + return self._wrapping_text_columns + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: str, + ): + text = str(value) if not text: - return super().paint(painter, option, index) - + return painter.save() painter.setClipRect(option.rect) - text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop - painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) + + # Use cached layout if available + cache_key = (text, option.rect.width()) + layout = self._cache.get(cache_key) + if layout is None: + layout = self._compute_layout(text, option) + self._cache[cache_key] = layout + + # Draw text + painter.setPen(option.palette.text().color()) + layout.draw(painter, option.rect.topLeft()) painter.restore() - def sizeHint(self, option, index): - text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # if not text: - # return super().sizeHint(option, index) + def _compute_layout( + self, text: str, option: QtWidgets.QStyleOptionViewItem + ) -> QtGui.QTextLayout: + """Compute and return the text layout for given text and option.""" + layout = self._get_layout(text, option.font) + text_option = QtGui.QTextOption() + text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere) + layout.setTextOption(text_option) + layout.beginLayout() + height = 0 + max_lines = 100 # safety cap, should never be more than 100 lines.. + for _ in range(max_lines): + line = layout.createLine() + if not line.isValid(): + break + line.setLineWidth(option.rect.width() - self.margin) + line.setPosition(QtCore.QPointF(self.margin / 2, height)) + line_height = line.height() + if line_height <= 0: + break # avoid negative or zero height lines to be added + height += line_height + layout.endLayout() + return layout + + def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout: + return QtGui.QTextLayout(text, font_option) + + def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize: + """Return a cached or approximate height; avoids costly recomputation.""" + text = str(index.data(QtCore.Qt.DisplayRole) or "") + view = self._parent + view.initViewItemOption(option) + if view.isColumnHidden(index.column()) or not view.isVisible() or not text: + return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin) + + # Use cache for consistent size computation + cache_key = (text, self.max_width) + if cache_key in self._cache: + layout = self._cache[cache_key] + height = 0 + for i in range(layout.lineCount()): + height += layout.lineAt(i).height() + return QtCore.QSize(self.max_width, int(height + self.margin)) + + # Approximate without layout (fast path) + metrics = option.fontMetrics + pixel_width = max(self._parent.columnWidth(index.column()), 100) + if pixel_width > 2000: # safeguard against uninitialized columns, may return large values + pixel_width = 100 + char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin) + wrapped_lines = textwrap.wrap(text, width=char_per_line) + lines = len(wrapped_lines) + return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) + + def estimate_chars_per_line( + self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int + ) -> int: + """Estimate number of characters that fit in a line for given width.""" + metrics = option.fontMetrics + elided = metrics.elidedText(text, Qt.ElideRight, column_width) + return len(elided.rstrip("…")) + + @SafeSlot(int, int, int) + @SafeSlot(int) + def _on_section_resized( + self, logical_index: int, old_size: int | None = None, new_size: int | None = None + ): + """Only update rows if a wrapped column was resized.""" + self._cache.clear() + # Make sure layout is computed first + QtCore.QTimer.singleShot(0, self._update_row_heights) + + def _update_row_heights(self): + """Efficiently adjust row heights based on wrapped columns.""" + view = self._parent + proxy = view.model() + option = QtWidgets.QStyleOptionViewItem() + view.initViewItemOption(option) + for row in range(proxy.rowCount()): + max_height = 18 + for column in self.wrapping_text_columns: + index = proxy.index(row, column) + delegate = view.itemDelegateForColumn(column) + hint = delegate.sizeHint(option, index) + max_height = max(max_height, hint.height()) + if view.rowHeight(row) != max_height: + view.setRowHeight(row, max_height) + + +class CenterCheckBoxDelegate(CustomDisplayDelegate): + """Custom checkbox delegate to center checkboxes in table cells.""" + + _paint_test_role = USER_CHECK_DATA_ROLE + + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): + super().__init__(parent) + colors: AccentColors = colors if colors else get_accent_colors() # type: ignore + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") + + def apply_theme(self, theme: str | None = None): + colors = get_accent_colors() + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[ + Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked + ], + ): + pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked + pix_rect = pixmap.rect() + pix_rect.moveCenter(option.rect.center()) + painter.drawPixmap(pix_rect.topLeft(), pixmap) + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QSortFilterProxyModel, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): + if event.type() != QtCore.QEvent.Type.MouseButtonRelease: + return False + current = model.data(index, USER_CHECK_DATA_ROLE) + new_state = ( + Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked + ) + return model.setData(index, new_state, USER_CHECK_DATA_ROLE) - # Use the actual column width - table = index.model().parent() # or store reference to QTableView - column_width = table.columnWidth(index.column()) # - 8 - doc = QtGui.QTextDocument() - doc.setDefaultFont(option.font) - doc.setTextWidth(column_width) - doc.setPlainText(text) +class DeviceValidatedDelegate(CustomDisplayDelegate): + """Custom delegate for displaying validated device configurations.""" - layout_height = doc.documentLayout().documentSize().height() - height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off - return QtCore.QSize(column_width, height) + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): + super().__init__(parent) + colors = colors if colors else get_accent_colors() + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) + self._icons = { + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), + } + + def apply_theme(self, theme: str | None = None): + colors = get_accent_colors() + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) + self._icons = { + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), + } + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[0, 1, 2], + ): + """ + Paint the validation status icon centered in the cell. + + Args: + painter (QtGui.QPainter): The painter object. + option (QtWidgets.QStyleOptionViewItem): The style options for the item. + index (QModelIndex): The model index of the item. + value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed. + Relates to ValidationStatus enum. + """ + if pixmap := self._icons.get(value): + pix_rect = pixmap.rect() + pix_rect.moveCenter(option.rect.center()) + painter.drawPixmap(pix_rect.topLeft(), pixmap) class DeviceTableModel(QtCore.QAbstractTableModel): @@ -121,62 +334,86 @@ class DeviceTableModel(QtCore.QAbstractTableModel): Sort logic is implemented directly on the data of the table view. """ - def __init__(self, device_config: list[dict] | None = None, parent=None): + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + configs_changed = QtCore.Signal(list, bool) + + def __init__(self, parent: DeviceTableModel | None = None): super().__init__(parent) - self._device_config = device_config or [] + self._device_config: list[dict[str, Any]] = [] + self._validation_status: dict[str, ValidationStatus] = {} + # TODO 882 keep in sync with HEADERS_HELP_MD self.headers = [ + "status", "name", "deviceClass", "readoutPriority", - "enabled", - "readOnly", + "onFailure", "deviceTags", "description", + "enabled", + "readOnly", + "softwareTrigger", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} + self._device_model_schema = Device.model_json_schema() ############################################### - ########## Overwrite custom Qt methods ######## + ########## Override custom Qt methods ######### ############################################### - def rowCount(self, parent=QtCore.QModelIndex()) -> int: + def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: return len(self._device_config) - def columnCount(self, parent=QtCore.QModelIndex()) -> int: + def columnCount( + self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex() + ) -> int: return len(self.headers) - def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: + def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + if section == 9: # softwareTrigger + return "softTrig" return self.headers[section] return None - def row_data(self, index: QtCore.QModelIndex) -> dict: + def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} return copy.deepcopy(self._device_config[index.row()]) - def data(self, index, role=QtCore.Qt.DisplayRole): + def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): """Return data for the given index and role.""" if not index.isValid(): return None row, col = index.row(), index.column() + + if col == 0 and role == Qt.ItemDataRole.DisplayRole: + dev_name = self._device_config[row].get("name", "") + return self._validation_status.get(dev_name, ValidationStatus.PENDING) + key = self.headers[col] - value = self._device_config[row].get(key) + value = self._device_config[row].get(key, None) + if value is None: + value = ( + self._device_model_schema.get("properties", {}).get(key, {}).get("default", None) + ) - if role == QtCore.Qt.DisplayRole: - if key in ("enabled", "readOnly"): + if role == Qt.ItemDataRole.DisplayRole: + if key in ("enabled", "readOnly", "softwareTrigger"): return bool(value) if key == "deviceTags": return ", ".join(str(tag) for tag in value) if value else "" + if key == "deviceClass": + return str(value).split(".")[-1] return str(value) if value is not None else "" - if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): - return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked - if role == QtCore.Qt.TextAlignmentRole: - if key in ("enabled", "readOnly"): - return QtCore.Qt.AlignCenter - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - if role == QtCore.Qt.FontRole: + if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"): + return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked + if role == Qt.ItemDataRole.TextAlignmentRole: + if key in ("enabled", "readOnly", "softwareTrigger"): + return Qt.AlignmentFlag.AlignCenter + return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + if role == Qt.ItemDataRole.FontRole: font = QtGui.QFont() return font return None @@ -184,18 +421,21 @@ def data(self, index, role=QtCore.Qt.DisplayRole): def flags(self, index): """Flags for the table model.""" if not index.isValid(): - return QtCore.Qt.NoItemFlags + return Qt.ItemFlag.NoItemFlags key = self.headers[index.column()] - if key in ("enabled", "readOnly"): - base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + base_flags = super().flags(index) | ( + Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled + ) + + if key in ("enabled", "readOnly", "softwareTrigger"): if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemIsUserCheckable + return base_flags | Qt.ItemFlag.ItemIsUserCheckable else: return base_flags # disable editing but still visible - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + return base_flags - def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: + def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: """ Method to set the data of the table. @@ -210,106 +450,172 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: if not index.isValid(): return False key = self.headers[index.column()] - row = index.row() - - if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: + if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled - self._device_config[row][key] = value == QtCore.Qt.Checked - self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole]) + self._device_config[index.row()][key] = value == Qt.CheckState.Checked + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, USER_CHECK_DATA_ROLE]) return True return False + #################################### + ############ Drag and Drop ######### + #################################### + + def mimeTypes(self) -> List[str]: + return [*super().mimeTypes(), MIME_DEVICE_CONFIG] + + def supportedDropActions(self): + return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction + + def dropMimeData(self, data, action, row, column, parent): + if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: + return False + if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: + return False + self.add_device_configs(json.loads(raw_data.toStdString())) + return True + #################################### ############ Public methods ######## #################################### - def get_device_config(self) -> list[dict]: - """Return the current device config (with checkbox updates applied).""" - return self._device_config + def get_device_config(self) -> list[dict[str, Any]]: + """Method to get the device configuration.""" + return copy.deepcopy(self._device_config) - def set_checkbox_enabled(self, column_name: str, enabled: bool): - """ - Enable/Disable the checkbox column. + def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: + _configs = self._device_config if configs is None else configs + return set(cfg.get("name") for cfg in _configs if cfg.get("name") is not None) # type: ignore - Args: - column_name (str): The name of the column to modify. - enabled (bool): Whether the checkbox should be enabled or disabled. - """ - if column_name in self._checkable_columns_enabled: - self._checkable_columns_enabled[column_name] = enabled - col = self.headers.index(column_name) - top_left = self.index(0, col) - bottom_right = self.index(self.rowCount() - 1, col) - self.dataChanged.emit( - top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole] - ) + def _name_exists_in_config(self, name: str, exists: bool): + if (name in self.device_names()) == exists: + return True + return not exists - def set_device_config(self, device_config: list[dict]): + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - Replace the device config. + Add devices to the model. Args: - device_config (list[dict]): The new device config to set. + device_configs (_DeviceCfgList): An iterable of device configurations to add. """ - self.beginResetModel() - self._device_config = list(device_config) - self.endResetModel() - - @SafeSlot(dict) - def add_device(self, device: dict): + already_in_list = [] + added_configs = [] + for cfg in device_configs: + if self._name_exists_in_config(name := cfg.get("name", ""), True): + logger.warning(f"Device {name} is already in the config. It will be updated.") + self.remove_configs_by_name([name]) + row = len(self._device_config) + self.beginInsertRows(QtCore.QModelIndex(), row, row) + self._device_config.append(copy.deepcopy(cfg)) + added_configs.append(cfg) + self.endInsertRows() + self.configs_changed.emit(device_configs, True) + + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ - Add an extra device to the device config at the bottom. + Remove devices from the model. Args: - device (dict): The device configuration to add. + device_configs (_DeviceCfgList): An iterable of device configurations to remove. """ - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(device) - self.endInsertRows() + removed = [] + for cfg in device_configs: + if cfg not in self._device_config: + logger.warning(f"Device {cfg.get('name')} does not exist in the model.") + continue + with self._remove_row(self._device_config.index(cfg)) as row: + removed.append(self._device_config.pop(row)) + self.configs_changed.emit(removed, False) + + def remove_configs_by_name(self, names: Iterable[str]): + configs = filter(lambda cfg: cfg is not None, (self.get_by_name(name) for name in names)) + self.remove_device_configs(configs) # type: ignore # Nones are filtered + + def get_by_name(self, name: str) -> dict[str, Any] | None: + for cfg in self._device_config: + if cfg.get("name") == name: + return cfg + logger.warning(f"Device {name} does not exist in the model.") + return None - @SafeSlot(int) - def remove_device_by_row(self, row: int): + @contextmanager + def _remove_row(self, row: int): + self.beginRemoveRows(QtCore.QModelIndex(), row, row) + try: + yield row + finally: + self.endRemoveRows() + + def set_device_config(self, device_configs: _DeviceCfgIter): """ - Remove one device row by index. This maps to the row to the source of the data model + Replace the device config. Args: - row (int): The index of the device row to remove. + device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. """ - if 0 <= row < len(self._device_config): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._device_config.pop(row) - self.endRemoveRows() + diff_names = self.device_names(device_configs) - self.device_names() + diff = [cfg for cfg in self._device_config if cfg.get("name") in diff_names] + self.beginResetModel() + self._device_config = copy.deepcopy(list(device_configs)) + self.endResetModel() + self.configs_changed.emit(diff, False) + self.configs_changed.emit(device_configs, True) - @SafeSlot(list) - def remove_devices_by_rows(self, rows: list[int]): + def clear_table(self): """ - Remove multiple device rows by their indices. - - Args: - rows (list[int]): The indices of the device rows to remove. + Clear the table. """ - for row in sorted(rows, reverse=True): - self.remove_device_by_row(row) + self.beginResetModel() + self._device_config.clear() + self.endResetModel() + self.configs_changed.emit(self._device_config, False) - @SafeSlot(str) - def remove_device_by_name(self, name: str): + def update_validation_status(self, device_name: str, status: int | ValidationStatus): """ - Remove one device row by name. + Handle device status changes. Args: - name (str): The name of the device to remove. + device_name (str): The name of the device. + status (int): The new status of the device. """ - for row, device in enumerate(self._device_config): - if device.get("name") == name: - self.remove_device_by_row(row) + if isinstance(status, int): + status = ValidationStatus(status) + if device_name not in self.device_names(): + logger.warning(f"Device {device_name} not found in table") + return + self._validation_status[device_name] = status + row = None + for ii, item in enumerate(self._device_config): + if item["name"] == device_name: + row = ii break + if row is None: + logger.warning( + f"Device {device_name} not found in device_status dict {self._validation_status}" + ) + return + # Emit dataChanged for column 0 (status column) + index = self.index(row, 0) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) + + def validation_statuses(self): + return copy.deepcopy(self._validation_status) class BECTableView(QtWidgets.QTableView): """Table View with custom keyPressEvent to delete rows with backspace or delete key""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) + + def model(self) -> DeviceFilterProxyModel: + return super().model() # type: ignore + def keyPressEvent(self, event) -> None: """ Delete selected rows with backspace or delete key @@ -317,50 +623,80 @@ def keyPressEvent(self, event) -> None: Args: event: keyPressEvent """ - if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): - return super().keyPressEvent(event) + if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): + return self.delete_selected() + return super().keyPressEvent(event) + + def contains_invalid_devices(self): + return ValidationStatus.FAILED in self.model().sourceModel().validation_statuses().values() + + def all_configs(self): + return self.model().sourceModel().get_device_config() - proxy_indexes = self.selectedIndexes() + def selected_configs(self): + return self.model().get_row_data(self.selectionModel().selectedRows()) + + def delete_selected(self): + proxy_indexes = self.selectionModel().selectedRows() if not proxy_indexes: return + model: DeviceTableModel = self.model().sourceModel() # access underlying model + self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - # Get unique rows (proxy indices) in reverse order so removal indexes stay valid - proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True) - # Map to source model rows - source_rows = [ - self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows - ] + def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: + """ + Map proxy model indices to source model row indices. - model: DeviceTableModel = self.model().sourceModel() # access underlying model - # Delegate confirmation and removal to helper - removed = self._confirm_and_remove_rows(model, source_rows) - if not removed: - return + Args: + proxy_indexes (list[QModelIndex]): List of proxy model indices. - def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: + Returns: + list[int]: List of source model row indices. + """ + proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) + return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) + + def _confirm_and_remove_rows( + self, model: DeviceTableModel, source_rows: list[QModelIndex] + ) -> bool: """ Prompt the user to confirm removal of rows and remove them from the model if accepted. Returns True if rows were removed, False otherwise. """ - cfg = model.get_device_config() - names = [str(cfg[r].get("name", "")) for r in sorted(source_rows)] + configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] + names = [cfg.get("name", "") for cfg in configs] + if not names: + logger.warning("No device names found for selected rows.") + return False + if self._remove_rows_msg_dialog(names): + model.remove_device_configs(configs) + return True + return False - msg = QtWidgets.QMessageBox(self) - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setWindowTitle("Confirm remove devices") - if len(names) == 1: - msg.setText(f"Remove device '{names[0]}'?") - else: - msg.setText(f"Remove {len(names)} devices?") - msg.setInformativeText("\n".join(names)) - msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) - msg.setDefaultButton(QtWidgets.QMessageBox.Cancel) + def _remove_rows_msg_dialog(self, names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + names (list[str]): List of device names to be removed. + + Returns: + bool: True if the user confirmed removal, False otherwise. + """ + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{names[0]}'?" if len(names) == 1 else f"Remove {len(names)} devices?" + ) + separator = "\n" if len(names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(names)) + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) + msg.setDefaultButton(QMessageBox.StandardButton.Cancel) res = msg.exec_() - if res == QtWidgets.QMessageBox.Ok: - model.remove_devices_by_rows(source_rows) - # TODO add signal for removed devices + if res == QMessageBox.StandardButton.Ok: return True return False @@ -372,7 +708,18 @@ def __init__(self, parent=None): self._hidden_rows = set() self._filter_text = "" self._enable_fuzzy = True - self._filter_columns = [0, 1] # name and deviceClass for search + self._filter_columns = [1, 2, 6] # name, deviceClass and description for search + self._status_order = { + ValidationStatus.VALID: 0, + ValidationStatus.PENDING: 1, + ValidationStatus.FAILED: 2, + } + + def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: + return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) + + def sourceModel(self) -> DeviceTableModel: + return super().sourceModel() # type: ignore def hide_rows(self, row_indices: list[int]): """ @@ -384,6 +731,14 @@ def hide_rows(self, row_indices: list[int]): self._hidden_rows.update(row_indices) self.invalidateFilter() + def lessThan(self, left, right): + """Add custom sorting for the status column""" + if left.column() != 0 or right.column() != 0: + return super().lessThan(left, right) + left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole) + right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole) + return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99) + def show_rows(self, row_indices: list[int]): """ Show specific rows in the model. @@ -422,7 +777,7 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: text = self._filter_text.lower() for column in self._filter_columns: index = model.index(source_row, column, source_parent) - data = str(model.data(index, QtCore.Qt.DisplayRole) or "") + data = str(model.data(index, Qt.ItemDataRole.DisplayRole) or "") if self._enable_fuzzy is True: match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) if match_ratio >= FUZZY_SEARCH_THRESHOLD: @@ -432,28 +787,68 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: return True return False + def flags(self, index): + return super().flags(index) | Qt.ItemFlag.ItemIsDropEnabled + + def supportedDropActions(self): + return self.sourceModel().supportedDropActions() + + def mimeTypes(self): + return self.sourceModel().mimeTypes() + + def dropMimeData(self, data, action, row, column, parent): + sp = self.mapToSource(parent) if parent.isValid() else QtCore.QModelIndex() + return self.sourceModel().dropMimeData(data, action, row, column, sp) + class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" + # Selected device configuration list[dict[str, Any]] + selected_devices = QtCore.Signal(list) # type: ignore + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + device_configs_changed = QtCore.Signal(list, bool) # type: ignore + RPC = False PLUGIN = False - devices_removed = QtCore.Signal(list) - def __init__(self, parent=None, client=None): + def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelectionSignal()): super().__init__(client=client, parent=parent, theme_update=True) - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(4) + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) # Setup table view self._setup_table_view() # Setup search view, needs table proxy to be iniditate self._setup_search() # Add widgets to main layout - self.layout.addLayout(self.search_controls) - self.layout.addWidget(self.table) + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) + + # Connect signals + self._model.configs_changed.connect(self.device_configs_changed.emit) + + def get_help_md(self) -> str: + """ + Generate Markdown help for a cell or header. + """ + pos = self.table.mapFromGlobal(QtGui.QCursor.pos()) + model: DeviceTableModel = self._model # access underlying model + index = self.table.indexAt(pos) + if index.isValid(): + column = index.column() + label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + if label == "softTrig": + label = "softwareTrigger" + return HEADERS_HELP_MD.get(label, "") + return "" def _setup_search(self): """Create components related to the search functionality""" @@ -489,143 +884,246 @@ def _setup_search(self): self.search_controls.addLayout(self.search_layout) self.search_controls.addSpacing(20) # Add some space between the search box and toggle self.search_controls.addLayout(self.fuzzy_layout) - QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) def _setup_table_view(self) -> None: """Setup the table view.""" # Model + Proxy self.table = BECTableView(self) - self.model = DeviceTableModel(parent=self.table) + self._model = DeviceTableModel(parent=self.table) self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self.model) + self.proxy.setSourceModel(self._model) self.table.setModel(self.proxy) self.table.setSortingEnabled(True) # Delegates - self.checkbox_delegate = CenterCheckBoxDelegate(self.table) - self.wrap_delegate = WrappingTextDelegate(self.table) + colors = get_accent_colors() + self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description + self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) + self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300) + # Add resize handling for wrapped delegate + header = self.table.horizontalHeader() + + self.table.setItemDelegateForColumn(0, self.validated_delegate) # status + self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name + self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass + self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority + self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure + self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags + self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description + self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly + self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger + + # Disable wrapping, use eliding, and smooth scrolling + self.table.setWordWrap(False) + self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) # Column resize policies - # TODO maybe we need here a flexible header options as deviceClass - # may get quite long for beamlines plugin repos header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly - # TODO maybe better stretch... - header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description - self.table.setColumnWidth(3, 82) - self.table.setColumnWidth(4, 82) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure + header.setSectionResizeMode( + 5, QHeaderView.ResizeMode.Interactive + ) # deviceTags: expand to fill + header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill + header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled + header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly + header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger + + self.table.setColumnWidth(0, 70) + self.table.setColumnWidth(5, 200) + self.table.setColumnWidth(6, 200) + self.table.setColumnWidth(7, 70) + self.table.setColumnWidth(8, 70) + self.table.setColumnWidth(9, 70) # Ensure column widths stay fixed - header.setMinimumSectionSize(70) + header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) - - # Enable resizing of column - header.sectionResized.connect(self.on_table_resized) + header.setStretchLastSection(False) + + # Resize policy for wrapped text delegate + self._resize_proxy = BECSignalProxy( + header.sectionResized, + rateLimit=25, + slot=self.wrapped_delegate._on_section_resized, + timeout=1.0, + ) # Selection behavior - self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) - # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) + # Connect model signals to autosize request + self._model.rowsInserted.connect(self._request_autosize_columns) + self._model.rowsRemoved.connect(self._request_autosize_columns) + self._model.modelReset.connect(self._request_autosize_columns) + self._model.dataChanged.connect(self._request_autosize_columns) + + def remove_selected_rows(self): + self.table.delete_selected() - def device_config(self) -> list[dict]: + def get_device_config(self) -> list[dict[str, Any]]: """Get the device config.""" - return self.model.get_device_config() + return self._model.get_device_config() def apply_theme(self, theme: str | None = None): self.checkbox_delegate.apply_theme(theme) + self.validated_delegate.apply_theme(theme) ###################################### ########### Slot API ################# ###################################### - @SafeSlot(int, int, int) - def on_table_resized(self, column, old_width, new_width): - """Handle changes to the table column resizing.""" - if column != len(self.model.headers) - 1: + def _request_autosize_columns(self, *args): + if not hasattr(self, "_autosize_timer"): + self._autosize_timer = QtCore.QTimer(self) + self._autosize_timer.setSingleShot(True) + self._autosize_timer.timeout.connect(self._autosize_columns) + self._autosize_timer.start(0) + + @SafeSlot() + def _autosize_columns(self): + if self._model.rowCount() == 0: return + for col in (1, 2, 3): + self.table.resizeColumnToContents(col) - for row in range(self.table.model().rowCount()): - index = self.table.model().index(row, column) - delegate = self.table.itemDelegate(index) - option = QtWidgets.QStyleOptionViewItem() - height = delegate.sizeHint(option, index).height() - self.table.setRowHeight(row, height) + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.table.clearSelection() + + @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ) -> None: + """ + Handle selection changes in the device table. + + Args: + selected (QtCore.QItemSelection): The selected items. + deselected (QtCore.QItemSelection): The deselected items. + """ + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + if not (selected_configs := list(self.table.selected_configs())): + return + self.selected_devices.emit(selected_configs) ###################################### ##### Ext. Slot API ################# ###################################### @SafeSlot(list) - def set_device_config(self, config: list[dict]): + def set_device_config(self, device_configs: _DeviceCfgIter): """ Set the device config. Args: - config (list[dict]): The device config to set. + config (Iterable[str,dict]): The device config to set. """ - self.model.set_device_config(config) + self._model.set_device_config(device_configs) @SafeSlot() - def clear_device_config(self): + def clear_device_configs(self): + """Clear the device configs.""" + self._model.clear_table() + + @SafeSlot(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - Clear the device config. + Add devices to the config. + + Args: + device_configs (dict[str, dict]): The device configs to add. """ - self.model.set_device_config([]) + self._model.add_device_configs(device_configs) - @SafeSlot(dict) - def add_device(self, device: dict): + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ - Add a device to the config. + Remove devices from the config. Args: - device (dict): The device to add. + device_configs (dict[str, dict]): The device configs to remove. """ - self.model.add_device(device) + self._model.remove_device_configs(device_configs) - @SafeSlot(int) @SafeSlot(str) - def remove_device(self, dev: int | str): + def remove_device(self, device_name: str): """ - Remove the device from the config either by row id, or device name. + Remove a device from the config. Args: - dev (int | str): The device to remove, either by row id or device name. + device_name (str): The name of the device to remove. """ - if isinstance(dev, int): - # TODO test this properly, check with proxy index and source index - # Use the proxy model to map to the correct row - model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0)) - self.model.remove_device_by_row(model_source_index.row()) - return - if isinstance(dev, str): - self.model.remove_device_by_name(dev) - return + self._model.remove_configs_by_name([device_name]) + + @SafeSlot(str, int) + def update_device_validation( + self, device_name: str, validation_status: int | ValidationStatus + ) -> None: + """ + Update the validation status of a device. + + Args: + device_name (str): The name of the device. + validation_status (int | ValidationStatus): The new validation status. + """ + self._model.update_validation_status(device_name, validation_status) if __name__ == "__main__": import sys + import numpy as np from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) window = DeviceTableView() + layout.addWidget(window) + # QPushButton + button = QtWidgets.QPushButton("Test status_update") + layout.addWidget(button) + + def _button_clicked(): + names = list(window._model.device_names()) + for name in names: + window.update_device_validation( + name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED + ) + + button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() + config.insert( + 0, + { + "name": "TestDevice", + "deviceClass": "bec.devices.MockDevice", + "description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.", + "deviceTags": ["test", "mock", "longtagnameexample"], + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ) + # names = [cfg.pop("name") for cfg in config] + # config_dict = {name: cfg for name, cfg in zip(names, config)} window.set_device_config(config) - window.show() + window.resize(1920, 1200) + widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py new file mode 100644 index 000000000..245080f32 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,100 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import traceback + +import yaml +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class DMConfigView(BECWidget, QtWidgets.QWidget): + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget() + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + # self.monaco_editor.setFixedHeight(600) + self.monaco_editor.set_readonly(True) + self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) + self.monaco_editor.editor.set_line_numbers_mode("off") + + def _customize_overlay(self): + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: list[dict]): + """Handle selection of a device from the device table.""" + if len(device) != 1: + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + else: + try: + text = yaml.dump(device[0], default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + except Exception: + content = traceback.format_exc() + logger.error(f"Error converting device to YAML:\n{content}") + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + self.monaco_editor.set_readonly(False) # Enable editing + text = text.rstrip() + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + config_view = DMConfigView() + layout.addWidget(config_view) + combo_box = QtWidgets.QComboBox() + config = config_view.client.device_manager._get_redis_device_config() + combo_box.addItems([""] + [str(v) for v, item in enumerate(config)]) + + def on_select(text): + if text == "": + config_view.on_select_config([]) + else: + config_view.on_select_config([config[int(text)]]) + + combo_box.currentTextChanged.connect(on_select) + layout.addWidget(combo_box) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py new file mode 100644 index 000000000..553462a00 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -0,0 +1,133 @@ +"""Module to visualize the docstring of a device class.""" + +from __future__ import annotations + +import inspect +import re +import textwrap +import traceback + +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import get_plugin_class, plugin_package_name +from bec_lib.utils.rpc_utils import rgetattr +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + +try: + import ophyd + import ophyd_devices + + READY_TO_VIEW = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + ophyd = None + + +def docstring_to_markdown(obj) -> str: + """ + Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown. + """ + raw = inspect.getdoc(obj) or "*No docstring available.*" + + # Dedent and normalize newlines + text = textwrap.dedent(raw).strip() + + md = "" + if hasattr(obj, "__name__"): + md += f"# {obj.__name__}\n\n" + + # Highlight section headers for Markdown + headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] + for h in headers: + text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) + + # Preserve code blocks (4+ space indented lines) + def fence_code(match: re.Match) -> str: + block = re.sub(r"^ {4}", "", match.group(0), flags=re.M) + return f"```\n{block}\n```" + + doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text) + + # Preserve normal line breaks for Markdown + lines = doc.splitlines() + processed_lines = [] + for line in lines: + if line.strip() == "": + processed_lines.append("") + else: + processed_lines.append(line + " ") + doc = "\n".join(processed_lines) + + md += doc + return md + + +class DocstringView(QtWidgets.QTextEdit): + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setReadOnly(True) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + if not READY_TO_VIEW: + self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") + self.setEnabled(False) + return + + def _set_text(self, text: str): + self.setReadOnly(False) + self.setMarkdown(text) + self.setReadOnly(True) + + @SafeSlot(list) + def on_select_config(self, device: list[dict]): + if len(device) != 1: + self._set_text("") + return + device_class = device[0].get("deviceClass", "") + self.set_device_class(device_class) + + @SafeSlot(str) + def set_device_class(self, device_class_str: str) -> None: + if not READY_TO_VIEW: + return + try: + module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) + except Exception: + logger.exception("Error retrieving docstring") + self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + config_view = DocstringView() + config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera") + layout.addWidget(config_view) + combo = QtWidgets.QComboBox() + combo.addItems( + [ + "", + "ophyd_devices.sim.sim_camera.SimCamera", + "ophyd.EpicsSignalWithRBV", + "ophyd.EpicsMotor", + "csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS", + ] + ) + combo.currentTextChanged.connect(config_view.set_device_class) + layout.addWidget(combo) + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py new file mode 100644 index 000000000..a73ada115 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -0,0 +1,418 @@ +"""Module to run a static tests for devices from a yaml config.""" + +from __future__ import annotations + +import enum +import re +from collections import deque +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from html import escape +from threading import Event, RLock +from typing import Any, Iterable + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server + import ophyd_devices + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class ValidationStatus(int, enum.Enum): + """Validation status for device configurations.""" + + PENDING = 0 # colors.default + VALID = 1 # colors.highlight + FAILED = 2 # colors.emergency + + +class DeviceValidationResult(QtCore.QObject): + """Simple object to inject validation signals into QRunnable.""" + + # Device validation signal, device_name, ValidationStatus as int, error message or '' + device_validated = QtCore.Signal(str, bool, str) + + +class DeviceTester(QtCore.QRunnable): + def __init__(self, config: dict) -> None: + super().__init__() + self.signals = DeviceValidationResult() + self.shutdown_event = Event() + + self._config = config + + self._max_threads = 4 + self._pending_event = Event() + self._lock = RLock() + self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester") + + self._pending_queue: deque[tuple[str, dict]] = deque([]) + self._active: set[str] = set() + + QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set()) + + def run(self): + if StaticDeviceTest is None: + logger.error("Ophyd devices or bec_server not available, cannot run validation.") + return + while not self.shutdown_event.is_set(): + self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s + if len(self._active) >= self._max_threads: + self._pending_event.clear() # it will be set again on removing something from active + continue + with self._lock: + if len(self._pending_queue) > 0: + item, cfg, connect = self._pending_queue.pop() + self._active.add(item) + fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect) + fut.__dict__["__device_name"] = item + fut.add_done_callback(self._done_cb) + self._safe_check_and_clear() + self._cleanup() + + def submit(self, devices: Iterable[tuple[str, dict, bool]]): + with self._lock: + self._pending_queue.extend(devices) + self._pending_event.set() + + @staticmethod + def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]: + tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None + results = tester.run_with_list_output(connect=connect) + return name, results[0].success, results[0].message + + def _safe_check_and_clear(self): + with self._lock: + if len(self._pending_queue) == 0: + self._pending_event.clear() + + def _safe_remove_from_active(self, name: str): + with self._lock: + self._active.remove(name) + self._pending_event.set() # check again once a completed task is removed + + def _done_cb(self, future: Future): + try: + name, success, message = future.result() + except CancelledError: + return + except Exception as e: + name, success, message = future.__dict__["__device_name"], False, str(e) + finally: + self._safe_remove_from_active(future.__dict__["__device_name"]) + self.signals.device_validated.emit(name, success, message) + + def _cleanup(self): ... + + +class ValidationListItem(QtWidgets.QWidget): + """Custom list item widget showing device name and validation status.""" + + def __init__(self, device_name: str, device_config: dict, parent=None): + """ + Initialize the validation list item. + + Args: + device_name (str): The name of the device. + device_config (dict): The configuration of the device. + validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. + parent (QtWidgets.QWidget, optional): The parent widget. + """ + super().__init__(parent) + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self.device_name = device_name + self.device_config = device_config + self.validation_msg = "Validation in progress..." + self._setup_ui() + + def _setup_ui(self): + """Setup the UI for the list item.""" + label = QtWidgets.QLabel(self.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + self._spinner = SpinnerWidget(parent=self) + self._spinner.speed = 80 + self._spinner.setFixedSize(24, 24) + self.main_layout.addWidget(self._spinner) + self._base_style = "font-weight: bold;" + self.setStyleSheet(self._base_style) + self._start_spinner() + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + @SafeSlot() + def on_validation_restart(self): + """Handle validation restart.""" + self.validation_msg = "" + self._start_spinner() + self.setStyleSheet("") # Check if this works as expected + + @SafeSlot(str) + def on_validation_failed(self, error_msg: str): + """Handle validation failure.""" + self.validation_msg = error_msg + colors = get_accent_colors() + self._stop_spinner() + self.main_layout.removeWidget(self._spinner) + self._spinner.deleteLater() + label = QtWidgets.QLabel("") + icon = material_icon("error", color=colors.emergency, size=(24, 24)) + label.setPixmap(icon) + self.main_layout.addWidget(label) + + +class DMOphydTest(BECWidget, QtWidgets.QWidget): + """Widget to test device configurations using ophyd devices.""" + + # Signal to emit the validation status of a device + device_validated = QtCore.Signal(str, int) + # validation_msg in markdown format + validation_msg_md = QtCore.Signal(str) + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + if not READY_TO_TEST: + self.setDisabled(True) + self.tester = None + else: + self.tester = DeviceTester({}) + self.tester.signals.device_validated.connect(self._on_device_validated) + QtCore.QThreadPool.globalInstance().start(self.tester) + self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} + # TODO Consider using the thread pool from BECConnector instead of fetching the global instance! + self._thread_pool = QtCore.QThreadPool.globalInstance() + + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(0) + + # We add a splitter between the list and the text box + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + self._main_layout.addWidget(self.splitter) + + self._setup_list_ui() + + def _setup_list_ui(self): + """Setup the list UI.""" + self._list_widget = QtWidgets.QListWidget(self) + self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self._list_widget) + # Connect signals + self._list_widget.currentItemChanged.connect(self._on_current_item_changed) + + @SafeSlot(list, bool) + @SafeSlot(list, bool, bool) + def change_device_configs( + self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False + ) -> None: + """Receive an update with device configs. + + Args: + device_configs (list[dict[str, Any]]): The updated device configurations. + """ + for cfg in device_configs: + name = cfg.get("name", "") + if added: + if name in self._device_list_items: + continue + if self.tester: + self._add_device(name, cfg) + self.tester.submit([(name, cfg, connect)]) + continue + if name not in self._device_list_items: + continue + self._remove_list_item(name) + + def _add_device(self, name, cfg): + item = QtWidgets.QListWidgetItem(self._list_widget) + widget = ValidationListItem(device_name=name, device_config=cfg) + + # wrap it in a QListWidgetItem + item.setSizeHint(widget.sizeHint()) + self._list_widget.addItem(item) + self._list_widget.setItemWidget(item, widget) + self._device_list_items[name] = item + + def _remove_list_item(self, device_name: str): + """Remove a device from the list.""" + # Get the list item + item = self._device_list_items.pop(device_name) + + # Retrieve the custom widget attached to the item + widget = self._list_widget.itemWidget(item) + if widget is not None: + widget.deleteLater() # clean up custom widget + + # Remove the item from the QListWidget + row = self._list_widget.row(item) + self._list_widget.takeItem(row) + + @SafeSlot(str, bool, str) + def _on_device_validated(self, device_name: str, success: bool, message: str): + """Handle the device validation result. + + Args: + device_name (str): The name of the device. + success (bool): Whether the validation was successful. + message (str): The validation message. + """ + logger.info(f"Device {device_name} validation result: {success}, message: {message}") + item = self._device_list_items.get(device_name, None) + if not item: + logger.error(f"Device {device_name} not found in the list.") + return + if success: + self._remove_list_item(device_name=device_name) + self.device_validated.emit(device_name, ValidationStatus.VALID.value) + else: + widget: ValidationListItem = self._list_widget.itemWidget(item) + widget.on_validation_failed(message) + self.device_validated.emit(device_name, ValidationStatus.FAILED.value) + + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle the current item change in the list widget. + + Args: + current (QListWidgetItem): The currently selected item. + previous (QListWidgetItem): The previously selected item. + """ + widget: ValidationListItem = self._list_widget.itemWidget(current) + if widget: + try: + formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg) + self.validation_msg_md.emit(formatted_md) + except Exception as e: + logger.error( + f"##Error formatting validation message for device {widget.device_name}:\n{e}" + ) + self.validation_msg_md.emit(widget.validation_msg) + else: + self.validation_msg_md.emit("") + + def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: + """ + Simple HTML formatting for validation messages, wrapping text naturally. + + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" + + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, + ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) + + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" + + return "\n\n---\n\n".join(blocks) + + def validation_running(self): + return self._device_list_items != {} + + @SafeSlot() + def clear_list(self): + """Clear the device list.""" + self._thread_pool.clear() + if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish + logger.error("Failed to wait for threads to finish. Removing items from the list.") + self._device_list_items.clear() + self._list_widget.clear() + self.validation_msg_md.emit("") + + def remove_device(self, device_name: str): + """Remove a device from the list.""" + item = self._device_list_items.pop(device_name, None) + if item: + self._list_widget.removeItemWidget(item) + + def cleanup(self): + if self.tester: + self.tester.shutdown_event.set() + return super().cleanup() + + +if __name__ == "__main__": + import sys + + from bec_lib.bec_yaml_loader import yaml_load + + # pylint: disable=ungrouped-imports + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + wid = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(wid) + wid.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + device_manager_ophyd_test = DMOphydTest() + try: + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + except Exception as e: + logger.error(f"Error loading config: {e}") + import os + + import bec_lib + + config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml") + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, True) + layout.addWidget(device_manager_ophyd_test) + device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") + device_manager_ophyd_test.resize(800, 600) + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + layout.addWidget(text_box) + device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown) + wid.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index be9382ead..9aa0e789f 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -1,6 +1,4 @@ import os -import re -from functools import partial from typing import Callable import bec_lib @@ -11,23 +9,17 @@ from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_qthemes import material_icon from pyqtgraph import SignalProxy -from qtpy.QtCore import QSize, QThreadPool, Signal -from qtpy.QtWidgets import ( - QFileDialog, - QListWidget, - QListWidgetItem, - QToolButton, - QVBoxLayout, - QWidget, -) +from qtpy.QtCore import QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -61,7 +53,8 @@ def __init__( self._q_threadpool = QThreadPool() self.ui = None self.init_ui() - self.dev_list: QListWidget = self.ui.device_list + self.dev_list = ListOfExpandableFrames(self, DeviceItem) + self.ui.verticalLayout.addWidget(self.dev_list) self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) self.proxy_device_update = SignalProxy( self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list @@ -116,7 +109,7 @@ def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ) def _create_add_dialog(self): - dialog = DeviceConfigDialog(parent=self, device=None, action="add") + dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add") dialog.open() def on_device_update(self, action: ConfigAction, content: dict) -> None: @@ -134,25 +127,15 @@ def on_device_update(self, action: ConfigAction, content: dict) -> None: def init_device_list(self): self.dev_list.clear() - self._device_items: dict[str, QListWidgetItem] = {} with RPCRegister.delayed_broadcast(): for device, device_obj in self.dev.items(): self._add_item_to_list(device, device_obj) def _add_item_to_list(self, device: str, device_obj): - def _updatesize(item: QListWidgetItem, device_item: DeviceItem): - device_item.adjustSize() - item.setSizeHint(QSize(device_item.width(), device_item.height())) - logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}") - - def _remove_item(item: QListWidgetItem): - self.dev_list.takeItem(self.dev_list.row(item)) - del self._device_items[device] - self.dev_list.sortItems() - - item = QListWidgetItem(self.dev_list) - device_item = DeviceItem( + + _, device_item = self.dev_list.add_item( + id=device, parent=self, device=device, devices=self.dev, @@ -160,18 +143,11 @@ def _remove_item(item: QListWidgetItem): config_helper=self._config_helper, q_threadpool=self._q_threadpool, ) - device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) - device_item.imminent_deletion.connect(partial(_remove_item, item)) + self.editing_enabled.connect(device_item.set_editable) self.device_update.connect(device_item.config_update) tooltip = self.dev[device]._config.get("description", "") device_item.setToolTip(tooltip) - device_item.broadcast_size_hint.connect(item.setSizeHint) - item.setSizeHint(device_item.sizeHint()) - - self.dev_list.setItemWidget(item, device_item) - self.dev_list.addItem(item) - self._device_items[device] = item @SafeSlot(dict, dict) def scan_status_changed(self, scan_info: dict, _: dict): @@ -200,20 +176,11 @@ def update_device_list(self, *_) -> None: Either way, the function will filter the devices based on the filter input text and update the device list. """ - filter_text = self.ui.filter_input.text() for device in self.dev: - if device not in self._device_items: + if device not in self.dev_list: # it is possible the device has just been added to the config self._add_item_to_list(device, self.dev[device]) - try: - self.regex = re.compile(filter_text, re.IGNORECASE) - except re.error: - self.regex = None # Invalid regex, disable filtering - for device in self.dev: - self._device_items[device].setHidden(False) - return - for device in self.dev: - self._device_items[device].setHidden(not self.regex.search(device)) + self.dev_list.update_filter(self.ui.filter_input.text()) @SafeSlot() def _load_from_file(self): diff --git a/bec_widgets/widgets/services/device_browser/device_browser.ui b/bec_widgets/widgets/services/device_browser/device_browser.ui index 9a2d4ce28..0903854c8 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.ui +++ b/bec_widgets/widgets/services/device_browser/device_browser.ui @@ -1,93 +1,90 @@ - Form - - - - 0 - 0 - 406 - 500 - - - - Form - - - - - - Device Browser - - - - - - - - Filter - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - ... - - - - - - - ... - - - - - - - ... - - - - - - - - - - - - + Form + + + + 0 + 0 + 406 + 500 + - - warning + + Form - - - - - - + + + + + Device Browser + + + + + + + + Filter + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + + warning + + + + + + + - - - - - - + + + \ No newline at end of file diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index 4a469dbba..ca1d66f7a 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -34,7 +34,11 @@ def __init__( @SafeSlot() def run(self): try: - if self.action in ["add", "update", "remove"]: + if self.action == "set": + self._process( + {"action": self.action, "config": self.config, "wait_for_response": False} + ) + elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( "Must be updating a device or be supplied a name for a new device" @@ -57,6 +61,9 @@ def process_simple_action(self, dev_name: str, action: ConfigAction | None = Non "config": {dev_name: self.config}, "wait_for_response": False, } + self._process(req_args) + + def _process(self, req_args: dict): timeout = ( self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20 ) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index 4df088a67..ceaea99ad 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -5,12 +5,14 @@ from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import field_validator -from qtpy.QtCore import QSize, Qt, QThreadPool, Signal +from pydantic import BaseModel, field_validator +from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, + QComboBox, QDialog, QDialogButtonBox, + QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, @@ -19,6 +21,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) @@ -29,6 +32,8 @@ logger = bec_logger.logger +_StdBtn = QDialogButtonBox.StandardButton + def _try_literal_eval(value: str): if value == "": @@ -39,79 +44,36 @@ def _try_literal_eval(value: str): raise ValueError(f"Entered config value {value} is not a valid python value!") from e -class DeviceConfigDialog(BECWidget, QDialog): +class DeviceConfigDialog(QDialog): RPC = False applied = Signal() + accepted_data = Signal(dict) def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update", "add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs ): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ self._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item super().__init__(parent=parent, **kwargs) - self._config_helper = config_helper or ConfigHelper( - self.client.connector, self.client._service_name, self.client.device_manager - ) - self._device = device - self._action: Literal["update", "add"] = action - self._q_threadpool = threadpool or QThreadPool() - self.setWindowTitle(f"Edit config for: {device}") + self._container = QStackedLayout() - self._container.setStackingMode(QStackedLayout.StackAll) + self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - user_warning = QLabel( - "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" - "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." - ) - user_warning.setWordWrap(True) - user_warning.setStyleSheet("QLabel { color: red; }") - self._layout.addWidget(user_warning) - self.get_bec_shortcuts() + self._data = {} self._add_form() - if self._action == "update": - self._form._validity.setVisible(False) - else: - self._set_schema_to_check_devices() - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) self._add_overlay() self._add_buttons() - + self.setWindowTitle("Add new device") self.setLayout(self._container) - self._form.validate_form() self._overlay_widget.setVisible(False) + self._form._validity.setVisible(True) + self._connect_form() - def _set_schema_to_check_devices(self): - class _NameValidatedConfigModel(DeviceConfigModel): - @field_validator("name") - @staticmethod - def _validate_name(value: str, *_): - if not value.isidentifier(): - raise ValueError( - f"Invalid device name: {value}. Device names must be valid Python identifiers." - ) - if value in self.dev: - raise ValueError(f"A device with name {value} already exists!") - return value - - self._form.set_schema(_NameValidatedConfigModel) + def _connect_form(self): + self._form.validity_proc.connect(self.enable_buttons_for_validity) + self._form.validate_form() def _add_form(self): self._form_widget = QWidget() @@ -119,16 +81,6 @@ def _add_form(self): self._form = DeviceConfigForm() self._layout.addWidget(self._form) - for row in self._form.enumerate_form_widgets(): - if ( - row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE - and self._action == "update" - ): - row.widget._set_pretty_display() - - if self._action == "update" and self._device in self.dev: - self._fetch_config() - self._fill_form() self._container.addWidget(self._form_widget) def _add_overlay(self): @@ -145,21 +97,12 @@ def _add_overlay(self): self._container.addWidget(self._overlay_widget) def _add_buttons(self): - self.button_box = QDialogButtonBox( - QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply) + self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel) + self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self._layout.addWidget(self.button_box) - def _fetch_config(self): - if ( - self.client.device_manager is not None - and self._device in self.client.device_manager.devices - ): - self._initial_config = self.client.device_manager.devices.get(self._device)._config - def _fill_form(self): self._form.set_data(DeviceConfigModel.model_validate(self._initial_config)) @@ -190,12 +133,16 @@ def updated_config(self): @SafeSlot(bool) def enable_buttons_for_validity(self, valid: bool): # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - for button in [ - self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok] - ]: + for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]: button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) + def _process_action(self): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data + @SafeSlot(popup_error=True) def apply(self): self._process_action() @@ -206,10 +153,138 @@ def accept(self): self._process_action() return super().accept() + +class EpicsMotorConfig(BaseModel): + prefix: str + + +class EpicsSignalROConfig(BaseModel): + read_pv: str + + +class EpicsSignalConfig(BaseModel): + read_pv: str + write_pv: str | None = None + + +class PresetClassDeviceConfigDialog(DeviceConfigDialog): + def __init__(self, *, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._device_models = { + "EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}), + "EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + "Custom": (None, {}), + } + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) + + def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): + for field_name, (value, editable) in constraints.items(): + if (widget := self._form.widget_dict.get(field_name)) is not None: + widget.setValue(value) + if not editable: + widget._set_pretty_display() + + def _replace_form(self, deviceconfig_cls_key): + self._form.deleteLater() + if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None: + devmodel, params = devmodel_params + else: + devmodel, params = None, {} + self._form = DeviceConfigForm(class_deviceconfig_item=devmodel) + self._apply_constraints(params) + self._layout.insertWidget(1, self._form) + self._connect_form() + + def _create_selection_box(self): + layout = QHBoxLayout() + self._selection_box = QComboBox() + self._selection_box.addItems(list(self._device_models.keys())) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + +class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): + def __init__( + self, + *, + parent=None, + device: str | None = None, + config_helper: ConfigHelper | None = None, + action: Literal["update"] | Literal["add"] = "update", + threadpool: QThreadPool | None = None, + **kwargs, + ): + """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model + for device specification in bec_lib.atlas_models. + + Args: + parent (QObject): the parent QObject + device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. + config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. + action (Literal["update", "add"]): the action which the form should perform on application or acceptance. + """ + self._device = device + self._q_threadpool = threadpool or QThreadPool() + self._config_helper = config_helper or ConfigHelper( + self.client.connector, self.client._service_name + ) + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + self._action: Literal["update", "add"] = action + user_warning = QLabel( + "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" + "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." + ) + user_warning.setWordWrap(True) + user_warning.setStyleSheet("QLabel { color: red; }") + self._layout.insertWidget(0, user_warning) + self.setWindowTitle( + f"Edit config for: {device}" if action == "update" else "Add new device" + ) + + if self._action == "update": + self._modify_for_update() + self._form.validity_proc.disconnect(self.enable_buttons_for_validity) + else: + self._set_schema_to_check_devices() + # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved + # self._form._validity.setVisible(True) + + def _modify_for_update(self): + for row in self._form.enumerate_form_widgets(): + if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE: + row.widget._set_pretty_display() + if self._device in self.dev: + self._fetch_config() + self._fill_form() + self._form._validity.setVisible(False) + + def _set_schema_to_check_devices(self): + class _NameValidatedConfigModel(DeviceConfigModel): + @field_validator("name") + @staticmethod + def _validate_name(value: str, *_): + if not value.isidentifier(): + raise ValueError( + f"Invalid device name: {value}. Device names must be valid Python identifiers." + ) + if value in self.dev: + raise ValueError(f"A device with name {value} already exists!") + return value + + self._form.set_schema(_NameValidatedConfigModel) + + def _fetch_config(self): + if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore + self._initial_config = device._config + def _process_action(self): updated_config = self.updated_config() if self._action == "add": - if (name := updated_config.get("name")) in self.dev: + if self.dev is not None and (name := updated_config.get("name")) in self.dev: raise ValueError( f"Can't create a new device with the same name as already existing device {name}!" ) @@ -249,12 +324,12 @@ def update_error(self, e: Exception): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def main(): # pragma: no cover @@ -269,10 +344,10 @@ def main(): # pragma: no cover app = QApplication(sys.argv) apply_theme("light") widget = QWidget() - widget.setLayout(QVBoxLayout()) + widget.setLayout(layout := QVBoxLayout()) device = QLineEdit() - widget.layout().addWidget(device) + layout.addWidget(device) def _destroy_dialog(*_): nonlocal dialog @@ -285,14 +360,14 @@ def accept(*args): def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DeviceConfigDialog(**kwargs) + kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} + dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() button = QPushButton("Show device dialog") - widget.layout().addWidget(button) + layout.addWidget(button) button.clicked.connect(_show_dialog) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 0b8c1aeb0..a783d9883 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -1,16 +1,20 @@ from __future__ import annotations +from functools import partial + from bec_lib.atlas_models import Device as DeviceConfigModel from pydantic import BaseModel from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, + DictFormItem, + FormItemSpec, ) @@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + def __init__( + self, + parent=None, + client=None, + pretty_display=False, + class_deviceconfig_item: type[BaseModel] | None = None, + **kwargs, + ): super().__init__( parent=parent, data_model=DeviceConfigModel, @@ -26,18 +37,28 @@ def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): client=client, **kwargs, ) + self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) self._widget_types["optional_bool"] = ( lambda spec: spec.item_type == bool | None, BoolFormItem, ) - self._validity.setVisible(False) + pred, _ = self._widget_types["dict"] + self._widget_types["dict"] = pred, self._custom_device_config_item + self._validity.setVisible(True) self._connect_to_theme_change() self.populate() def _post_init(self): ... + def _custom_device_config_item(self, spec: FormItemSpec): + if spec.name != "deviceConfig": + return DictFormItem + if self._class_deviceconfig_item is not None: + return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) + return DictFormItem + def set_pretty_display_theme(self, theme: str | None = None): if theme is None: theme = get_theme_name() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index def709eb2..45f233cb6 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -18,7 +18,7 @@ CommunicateConfigAction, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( DeviceConfigForm, @@ -35,9 +35,6 @@ class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( @@ -94,7 +91,7 @@ def _create_title_layout(self, title: str, icon: str): @SafeSlot() def _create_edit_dialog(self): - dialog = DeviceConfigDialog( + dialog = DirectUpdateDeviceConfigDialog( parent=self, device=self.device, config_helper=self._config_helper, diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8a..7c36594ee 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client): yield dev_browser -def test_device_browser_init_with_devices(device_browser): +def test_device_browser_init_with_devices(device_browser: DeviceBrowser): """ Test that the device browser is initialized with the correct number of devices. """ - device_list = device_browser.ui.device_list + device_list = device_browser.dev_list assert device_list.count() == len(device_browser.dev) @@ -58,11 +58,11 @@ def test_device_browser_filtering( expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev) def num_visible(item_dict): - return len(list(filter(lambda i: not i.isHidden(), item_dict.values()))) + return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values()))) device_browser.ui.filter_input.setText(search_term) qtbot.wait(100) - assert num_visible(device_browser._device_items) == expected + assert num_visible(device_browser.dev_list._item_dict) == expected def test_device_item_mouse_press_event(device_browser, qtbot): @@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot): Test that the mousePressEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton) @@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot): Test that the form is displayed when the item is expanded, and that the expansion is triggered by clicking on the expansion button, the title, or the device icon """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100) @@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot): form = tab_widget.widget(0).layout().itemAt(0).widget() assert widget.expanded assert (name_field := form.widget_dict.get("name")) is not None - qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500) + qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded @@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt """ Test that the mousePressEvent is triggered correctly and initiates a drag. """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) device_name = widget.device with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec: with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata: @@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot): Test that the mouseDoubleClickEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseDClick(widget, Qt.LeftButton) def test_device_deletion(device_browser, qtbot): - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) widget._config_helper = mock.MagicMock() - assert widget.device in device_browser._device_items + assert widget.device in device_browser.dev_list._item_dict qtbot.mouseClick(widget.delete_button, Qt.LeftButton) - qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000) + qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000) def test_signal_display(mocked_client, qtbot): diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index 54bcbe356..219350d2b 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -6,7 +6,7 @@ from bec_widgets.utils.forms_from_types.items import StrFormItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, _try_literal_eval, ) @@ -29,7 +29,7 @@ def mock_client(): @pytest.fixture def update_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - update_dialog = DeviceConfigDialog( + update_dialog = DirectUpdateDeviceConfigDialog( device="test_device", config_helper=MagicMock(), client=mock_client ) qtbot.addWidget(update_dialog) @@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot): @pytest.fixture def add_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - add_dialog = DeviceConfigDialog( + add_dialog = DirectUpdateDeviceConfigDialog( device=None, config_helper=MagicMock(), client=mock_client, action="add" ) qtbot.addWidget(add_dialog) diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 02ae550d8..7ab73e946 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base): assert device_input_base.devices == [] -def test_device_input_base_init_with_config(mocked_client): +def test_device_input_base_init_with_config(qtbot, mocked_client): """Test init with Config""" config = { "widget_class": "DeviceInputWidget", @@ -55,6 +55,10 @@ def test_device_input_base_init_with_config(mocked_client): widget2 = DeviceInputWidget( client=mocked_client, config=DeviceInputConfig.model_validate(config) ) + qtbot.addWidget(widget) + qtbot.addWidget(widget2) + qtbot.waitExposed(widget) + qtbot.waitExposed(widget2) for w in [widget, widget2]: assert w.config.gui_id == "test_gui_id" assert w.config.device_filter == ["Positioner"] diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py new file mode 100644 index 000000000..b4454cfd7 --- /dev/null +++ b/tests/unit_tests/test_device_manager_components.py @@ -0,0 +1,869 @@ +"""Unit tests for device_manager_components module.""" + +from unittest import mock + +import pytest +import yaml +from bec_lib.atlas_models import Device as DeviceModel +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD +from bec_widgets.widgets.control.device_manager.components.device_table_view import ( + USER_CHECK_DATA_ROLE, + BECTableView, + CenterCheckBoxDelegate, + CustomDisplayDelegate, + DeviceFilterProxyModel, + DeviceTableModel, + DeviceTableView, + DeviceValidatedDelegate, + DictToolTipDelegate, + WrappingTextDelegate, +) +from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView +from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import ( + DocstringView, + docstring_to_markdown, +) +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus + + +### Constants #### +def test_constants_headers_help_md(): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "status", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value, str) + assert value.startswith("## ") # Each entry should start with a markdown header + + +### DM Docstring View #### + + +@pytest.fixture +def docstring_view(qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +class NumPyStyleClass: + """Perform simple signal operations. + + Parameters + ---------- + data : numpy.ndarray + Input signal data. + + Attributes + ---------- + data : numpy.ndarray + The original signal data. + + Returns + ------- + SignalProcessor + An initialized signal processor instance. + """ + + +class GoogleStyleClass: + """Analyze spectral properties of a signal. + + Args: + frequencies (list[float]): Frequency bins. + amplitudes (list[float]): Corresponding amplitude values. + + Returns: + dict: A dictionary with spectral analysis results. + + Raises: + ValueError: If input lists are of unequal length. + """ + + +def test_docstring_view_docstring_to_markdown(): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting + + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting + + +def test_docstring_view_on_select_config(docstring_view): + """Test the DocstringView on_select_config method. Called with single and multiple devices.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, + ): + # Test with single device + docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + +def test_docstring_view_set_device_class(docstring_view): + """Test the DocstringView set_device_class method with valid and invalid class names.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +#### DM Config View #### + + +@pytest.fixture +def dm_config_view(qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_dm_config_view_initialization(dm_config_view): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select single device to show config" + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + +def test_dm_config_view_on_select_config(dm_config_view): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +### Device Table View #### +# Not sure how to nicely test the delegates. + + +@pytest.fixture +def mock_table_view(qtbot): + """Create a mock table view for delegate testing.""" + table = BECTableView() + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table + + +@pytest.fixture +def device_table_model(qtbot, mock_table_view): + """Fixture to create a DeviceTableModel instance.""" + model = DeviceTableModel(mock_table_view) + yield model + + +@pytest.fixture +def device_proxy_model(qtbot, mock_table_view, device_table_model): + """Fixture to create a DeviceFilterProxyModel instance.""" + model = DeviceFilterProxyModel(mock_table_view) + model.setSourceModel(device_table_model) + mock_table_view.setModel(model) + yield model + + +@pytest.fixture +def qevent_mock() -> QtCore.QEvent: + """Create a mock QEvent for testing.""" + event = mock.MagicMock(spec=QtCore.QEvent) + yield event + + +@pytest.fixture +def view_mock() -> QtWidgets.QAbstractItemView: + """Create a mock QAbstractItemView for testing.""" + view = mock.MagicMock(spec=QtWidgets.QAbstractItemView) + yield view + + +@pytest.fixture +def index_mock(device_proxy_model) -> QtCore.QModelIndex: + """Create a mock QModelIndex for testing.""" + index = mock.MagicMock(spec=QtCore.QModelIndex) + index.model.return_value = device_proxy_model + yield index + + +@pytest.fixture +def option_mock() -> QtWidgets.QStyleOptionViewItem: + """Create a mock QStyleOptionViewItem for testing.""" + option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem) + yield option + + +@pytest.fixture +def painter_mock() -> QtGui.QPainter: + """Create a mock QPainter for testing.""" + painter = mock.MagicMock(spec=QtGui.QPainter) + yield painter + + +def test_tooltip_delegate( + mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model +): + """Test DictToolTipDelegate tooltip generation.""" + # No ToolTip event + delegate = DictToolTipDelegate(mock_table_view) + qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel + # nothing should happen + with mock.patch.object( + QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False + ) as super_mock: + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + + super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock) + assert result is False + + # ToolTip event + qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip + qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20)) + + source_model = device_proxy_model.sourceModel() + with ( + mock.patch.object( + source_model, "get_row_data", return_value={"description": "Mock description"} + ), + mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock), + mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock, + ): + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock) + assert result is True + + +def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock): + """Test CustomDisplayDelegate initialization.""" + delegate = CustomDisplayDelegate(mock_table_view) + + # Test _test_custom_paint, with None and a value + def _return_data(): + yield None + yield "Test Value" + + proxy_model = index_mock.model() + with ( + mock.patch.object(proxy_model, "data", side_effect=_return_data()), + mock.patch.object( + QtWidgets.QStyledItemDelegate, "paint", return_value=None + ) as super_paint_mock, + mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock, + ): + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock) + custom_paint_mock.assert_not_called() + # Call again for the value case + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock) + assert super_paint_mock.call_count == 2 + custom_paint_mock.assert_called_once_with( + painter_mock, option_mock, index_mock, "Test Value" + ) + + +def test_center_checkbox_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test CenterCheckBoxDelegate initialization.""" + delegate = CenterCheckBoxDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked) + # Check that the checkbox is centered + pixrect = delegate._icon_checked.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked) + + model = index_mock.model() + + # Editor event with non-check state role + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange + assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + + # Editor event with check state role but not mouse button event + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease + with ( + mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked), + mock.patch.object(model, "setData") as mock_model_set, + ): + delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + mock_model_set.assert_called_once_with( + index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE + ) + + +def test_device_validated_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + # Invalid value + delegate = DeviceValidatedDelegate(mock_table_view) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value") + painter_mock.drawPixmap.assert_not_called() + + # Valid value + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value) + icon = delegate._icons[ValidationStatus.VALID.value] + pixrect = icon.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon) + + +def test_wrapping_text_delegate_do_custom_paint( + mock_table_view, painter_mock, option_mock, index_mock +): + """Test WrappingTextDelegate _do_custom_paint method.""" + delegate = WrappingTextDelegate(mock_table_view) + + # First case, empty text, nothing should happen + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "") + painter_mock.setPen.assert_not_called() + layout_mock = mock.MagicMock() + + def _layout_comput_return(*args, **kwargs): + return layout_mock + + layout_mock.draw.return_value = None + with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return): + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring") + layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft()) + + +TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20) +TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate." + + +def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock): + """Test WrappingTextDelegate _compute_layout method.""" + delegate = WrappingTextDelegate(mock_table_view) + layout_mock = mock.MagicMock(spec=QtGui.QTextLayout) + + # This combination should yield 4 lines + with mock.patch.object(delegate, "_get_layout", return_value=layout_mock): + layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine) + mock_line.height.return_value = 10 + mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False]) + + option_mock.rect = TEST_RECT_FOR + option_mock.font = QtGui.QFont() + layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock) + assert layout.createLine.call_count == 4 # pylint: disable=E1101 + assert mock_line.setPosition.call_count == 3 + assert mock_line.setPosition.call_args_list[-1] == mock.call( + QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit + ) + + +def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock): + """Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines.""" + delegate = WrappingTextDelegate(mock_table_view) + assert delegate.margin == 6 + with ( + mock.patch.object(mock_table_view, "initViewItemOption"), + mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]), + mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]), + ): + # Test with empty text, should return height + 2*margin + index_mock.data.return_value = "" + option_mock.rect = TEST_RECT_FOR + font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont()) + size = delegate.sizeHint(option_mock, index_mock) + assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin) + + # Now test with the text that should wrap to 4 lines + index_mock.data.return_value = TEST_TEXT_WITH_4_LINES + size = delegate.sizeHint(option_mock, index_mock) + # The estimate goes to 5 lines + 2* margin + expected_lines = 5 + assert size == QtCore.QSize( + 100, font_metrics.height() * expected_lines + 2 * delegate.margin + ) + + +def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model): + """Test WrappingTextDelegate update_row_heights method.""" + device_cfg = DeviceModel( + name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline" + ).model_dump() + # Add single device to config + delegate = WrappingTextDelegate(mock_table_view) + row_heights = [25, 40] + + with mock.patch.object( + delegate, + "sizeHint", + side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])], + ): + mock_table_view.setItemDelegateForColumn(5, delegate) + mock_table_view.setItemDelegateForColumn(6, delegate) + device_proxy_model.sourceModel().set_device_config([device_cfg]) + assert delegate._wrapping_text_columns is None + assert mock_table_view.rowHeight(0) == 30 # Default height + delegate._update_row_heights() + assert delegate._wrapping_text_columns == [5, 6] + assert mock_table_view.rowHeight(0) == max(row_heights) + + +def test_device_validation_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + delegate = DeviceValidatedDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID) + # Check that the checkbox is centered + + pixrect = delegate._icons[ValidationStatus.VALID.value].rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with( + pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value] + ) + + # Should not be called if invalid value + delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10) + + # Check that the checkbox is centered + assert painter_mock.drawPixmap.call_count == 1 + + +### +# Test DeviceTableModel & DeviceFilterProxyModel +### + + +def test_device_table_model_data(device_proxy_model): + """Test the device table model data retrieval.""" + source_model = device_proxy_model.sourceModel() + test_device = { + "status": ValidationStatus.PENDING, + "name": "test_device", + "deviceClass": "TestClass", + "readoutPriority": "baseline", + "onFailure": "retry", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + "deviceTags": ["tag1", "tag2"], + "description": "Test device", + } + source_model.add_device_configs([test_device]) + assert source_model.rowCount() == 1 + assert source_model.columnCount() == 10 + + # Check data retrieval for each column + expected_data = { + 0: ValidationStatus.PENDING, # Default status + 1: "test_device", # name + 2: "TestClass", # deviceClass + 3: "baseline", # readoutPriority + 4: "retry", # onFailure + 5: "tag1, tag2", # deviceTags + 6: "Test device", # description + 7: True, # enabled + 8: False, # readOnly + 9: True, # softwareTrigger + } + + for col, expected in expected_data.items(): + index = source_model.index(0, col) + data = source_model.data(index, QtCore.Qt.DisplayRole) + assert data == expected + + +def test_device_table_model_with_data(device_table_model, device_proxy_model): + """Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data.""" + # Create 3 test devices - names NOT alphabetically sorted + test_devices = [ + { + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + "readOnly": False, + "readoutPriority": "baseline", + "deviceTags": ["tag1", "tag2"], + "description": "Test device Z", + }, + { + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + "readOnly": True, + "readoutPriority": "primary", + "deviceTags": ["tag3"], + "description": "Test device A", + }, + { + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + "readOnly": False, + "readoutPriority": "secondary", + "deviceTags": [], + "description": "Test device B", + }, + ] + + # Add devices to source model + device_table_model.add_device_configs(test_devices) + + # Check source model has 3 rows and proper columns + assert device_table_model.rowCount() == 3 + assert device_table_model.columnCount() == 10 + + # Check proxy model propagates the data + assert device_proxy_model.rowCount() == 3 + assert device_proxy_model.columnCount() == 10 + + # Verify data propagation through proxy - check names in original order + for i, expected_device in enumerate(test_devices): + proxy_index = device_proxy_model.index(i, 1) # Column 1 is name + source_index = device_proxy_model.mapToSource(proxy_index) + source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + assert source_data == expected_device["name"] + + # Check proxy data matches source + proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole) + assert proxy_data == source_data + + # Verify all columns are accessible + headers = device_table_model.headers + for col, header in enumerate(headers): + header_data = device_table_model.headerData( + col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole + ) + assert header_data is not None + + +def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort.""" + # Use same test data as above - zebra, alpha, beta (not alphabetically sorted) + test_devices = [ + { + "status": ValidationStatus.VALID, + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + }, + { + "status": ValidationStatus.PENDING, + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + }, + { + "status": ValidationStatus.FAILED, + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + }, + ] + + device_table_model.add_device_configs(test_devices) + + # Verify initial order (unsorted) + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + + # Enable sorting and sort by name column (column 1) + mock_table_view.setSortingEnabled(True) + # header = mock_table_view.horizontalHeader() + # qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton) + device_proxy_model.sort(1, QtCore.Qt.AscendingOrder) + + # After sorting, verify alphabetical order: alpha, beta, zebra + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + + +def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (C): Remove rows from BECTableView and verify propagation.""" + # Set up test data + test_devices = [ + {"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True}, + {"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False}, + {"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_table_model.rowCount() == 3 + assert device_proxy_model.rowCount() == 3 + + # Mock the confirmation dialog to first cancel, then confirm + with mock.patch.object( + mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True] + ) as mock_confirm: + + # Create mock selection for middle device (device_to_remove at row 1) + selection_model = mock.MagicMock() + proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column + selection_model.selectedRows.return_value = [proxy_index_to_remove] + + mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model) + + # Verify the device we're about to remove + device_name_to_remove = device_proxy_model.data( + device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole + ) + assert device_name_to_remove == "device_to_remove" + + # Call delete_selected method + mock_table_view.delete_selected() + + # Verify confirmation was called + mock_confirm.assert_called_once() + + assert device_table_model.rowCount() == 3 # No change on first call + assert device_proxy_model.rowCount() == 3 + + # Call delete_selected again, this time it should confirm + mock_table_view.delete_selected() + + # Check that the device was removed from source model + assert device_table_model.rowCount() == 2 + assert device_proxy_model.rowCount() == 2 + + # Verify the remaining devices are correct + remaining_names = [] + for i in range(device_proxy_model.rowCount()): + name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole) + remaining_names.append(name) + + assert "device_to_remove" not in remaining_names + + +def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model): + """Test DeviceFilterProxyModel text filtering functionality.""" + # Set up test data with different device names and classes + test_devices = [ + {"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"}, + {"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"}, + {"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_proxy_model.rowCount() == 3 + + # Test filtering by name + device_proxy_model.setFilterText("motor") + assert device_proxy_model.rowCount() == 2 + # Should show 2 rows (motor_x and motor_y) + visible_count = 0 + for i in range(device_proxy_model.rowCount()): + if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + continue + visible_count += 1 + + # Test filtering by device class + device_proxy_model.setFilterText("EpicsDetector") + # Should show 1 row (detector_main) + detector_visible = False + assert device_proxy_model.rowCount() == 1 + for i in range(device_table_model.rowCount()): + if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + source_index = device_table_model.index(i, 1) # Name column + name = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + if name == "detector_main": + detector_visible = True + break + assert detector_visible + + # Clear filter + device_proxy_model.setFilterText("") + assert device_proxy_model.rowCount() == 3 + # Should show all 3 rows again + all_visible = all( + device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()) + for i in range(device_table_model.rowCount()) + ) + assert all_visible + + +### +# Test DeviceTableView +### + + +@pytest.fixture +def device_table_view(qtbot): + """Fixture to create a DeviceTableView instance.""" + view = DeviceTableView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_device_table_view_initialization(qtbot, device_table_view): + """Test the DeviceTableView search method.""" + + # Check that the search input fields are properly initialized and connected + qtbot.keyClicks(device_table_view.search_input, "zebra") + qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000) + qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton) + qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000) + + # Check table setup + + # header + header = device_table_view.table.horizontalHeader() + assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags + assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description + + # table selection + assert ( + device_table_view.table.selectionBehavior() + == QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + assert ( + device_table_view.table.selectionMode() + == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection + ) + + +def test_device_table_theme_update(device_table_view): + """Test DeviceTableView apply_theme method.""" + # Check apply theme propagates + with ( + mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply, + mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated, + ): + device_table_view.apply_theme("dark") + mock_apply.assert_called_once_with("dark") + mock_validated.assert_called_once_with("dark") + + +def test_device_table_view_updates(device_table_view): + """Test DeviceTableView methods that update the view and model.""" + # Test theme update triggered.. + + cfgs = [ + {"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True}, + {"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False}, + {"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True}, + ] + with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize: + # Should be called once for rowsInserted + device_table_view.set_device_config(cfgs) + assert device_table_view.get_device_config() == cfgs + mock_autosize.assert_called_once() + # Update validation status, should be called again + device_table_view.update_device_validation("test_device", ValidationStatus.VALID) + assert mock_autosize.call_count == 2 + # Remove a device, should triggere also a _request_autosize_columns call + device_table_view.remove_device_configs([cfgs[0]]) + assert device_table_view.get_device_config() == cfgs[1:] + assert mock_autosize.call_count == 3 + # Remove one device manually + device_table_view.remove_device("another_device") # Should remove the last device + assert device_table_view.get_device_config() == cfgs[2:] + assert mock_autosize.call_count == 4 + # Reset the model should call it once again + device_table_view.clear_device_configs() + assert mock_autosize.call_count == 5 + assert device_table_view.get_device_config() == [] + + +def test_device_table_view_get_help_md(device_table_view): + """Test DeviceTableView get_help_md method.""" + with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at: + mock_index_at.isValid = mock.MagicMock(return_value=True) + with mock.patch.object(device_table_view, "_model") as mock_model: + mock_model.headerData = mock.MagicMock(side_effect=["softTrig"]) + # Second call is True, should return the corresponding help md + assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"] diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py new file mode 100644 index 000000000..a85be7319 --- /dev/null +++ b/tests/unit_tests/test_device_manager_view.py @@ -0,0 +1,224 @@ +"""Unit tests for the device manager view""" + +# pylint: disable=protected-access,redefined-outer-name + +from unittest import mock + +import pytest +from qtpy import QtCore +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( + ConfigChoiceDialog, + DeviceManagerView, +) +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, +) + + +@pytest.fixture +def dm_view(qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def config_choice_dialog(qtbot, dm_view): + """Fixture for ConfigChoiceDialog.""" + dialog = ConfigChoiceDialog(dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == dm_view + + # Test dialog components + with ( + mock.patch.object(config_choice_dialog, "accept") as mock_accept, + mock.patch.object(config_choice_dialog, "reject") as mock_reject, + ): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.REPLACE + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.ADD + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_accept.assert_not_called() + mock_reject.assert_called_once() + assert config_choice_dialog.result() == config_choice_dialog.CANCEL + + +class TestDeviceManagerViewInitialization: + """Test class for DeviceManagerView initialization and basic components.""" + + def test_dock_manager_initialization(self, dm_view): + """Test that the QtAds DockManager is properly initialized.""" + assert dm_view.dock_manager is not None + assert dm_view.dock_manager.centralWidget() is not None + + def test_central_widget_is_device_table_view(self, dm_view): + """Test that the central widget is DeviceTableView.""" + central_widget = dm_view.dock_manager.centralWidget().widget() + assert isinstance(central_widget, DeviceTableView) + assert central_widget is dm_view.device_table_view + + def test_dock_widgets_exist(self, dm_view): + """Test that all required dock widgets are created.""" + dock_widgets = dm_view.dock_manager.dockWidgets() + + # Check that we have the expected number of dock widgets + assert len(dock_widgets) >= 4 + + # Check for specific widget types + widget_types = [dock.widget().__class__ for dock in dock_widgets] + + assert DMConfigView in widget_types + assert DMOphydTest in widget_types + assert DocstringView in widget_types + + def test_toolbar_initialization(self, dm_view): + """Test that the toolbar is properly initialized with expected bundles.""" + assert dm_view.toolbar is not None + assert "IO" in dm_view.toolbar.bundles + assert "Table" in dm_view.toolbar.bundles + + def test_toolbar_components_exist(self, dm_view): + """Test that all expected toolbar components exist.""" + expected_components = [ + "load", + "save_to_disk", + "load_redis", + "update_config_redis", + "reset_composed", + "add_device", + "remove_device", + "rerun_validation", + ] + + for component in expected_components: + assert dm_view.toolbar.components.exists(component) + + def test_signal_connections(self, dm_view): + """Test that signals are properly connected between components.""" + # Test that device_table_view signals are connected + assert dm_view.device_table_view.selected_devices is not None + assert dm_view.device_table_view.device_configs_changed is not None + + # Test that ophyd_test_view signals are connected + assert dm_view.ophyd_test_view.device_validated is not None + + +class TestDeviceManagerViewIOBundle: + """Test class for DeviceManagerView IO bundle actions.""" + + def test_io_bundle_exists(self, dm_view): + """Test that IO bundle exists and contains expected actions.""" + assert "IO" in dm_view.toolbar.bundles + io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"] + for action in io_actions: + assert dm_view.toolbar.components.exists(action) + + def test_load_file_action_triggered(self, tmp_path, dm_view): + """Test load file action trigger mechanism.""" + + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load" + ) as mock_yaml_load, + mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog, + ): + mock_yaml_data = {"device1": {"param1": "value1"}} + mock_yaml_load.return_value = mock_yaml_data + + # Setup dialog mock + dm_view.toolbar.components._components["load"].action.action.triggered.emit() + mock_yaml_load.assert_called_once_with(tmp_path) + mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}]) + + def test_save_config_to_file(self, tmp_path, dm_view): + """Test saving config to file.""" + yaml_path = tmp_path / "test_save.yaml" + mock_config = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path), + mock.patch.object( + dm_view.device_table_view, "get_device_config", return_value=mock_config + ), + ): + dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() + assert yaml_path.exists() + + +class TestDeviceManagerViewTableBundle: + """Test class for DeviceManagerView Table bundle actions.""" + + def test_table_bundle_exists(self, dm_view): + """Test that Table bundle exists and contains expected actions.""" + assert "Table" in dm_view.toolbar.bundles + table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] + for action in table_actions: + assert dm_view.toolbar.components.exists(action) + + @mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question" + ) + def test_reset_composed_view(self, mock_question, dm_view): + """Test reset composed view when user confirms.""" + with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: + mock_question.return_value = QMessageBox.StandardButton.Yes + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_called_once() + mock_clear.reset_mock() + mock_question.return_value = QMessageBox.StandardButton.No + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_not_called() + + def test_add_device_action_connected(self, dm_view): + """Test add device action opens dialog correctly.""" + with mock.patch.object(dm_view, "_add_device_action") as mock_add: + dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() + mock_add.assert_called_once() + + def test_remove_device_action(self, dm_view): + """Test remove device action.""" + with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove: + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + mock_remove.assert_called_once() + + def test_rerun_device_validation(self, dm_view): + """Test rerun device validation action.""" + cfgs = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change, + mock.patch.object( + dm_view.device_table_view.table, "selected_configs", return_value=cfgs + ), + ): + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + mock_change.assert_called_once_with(cfgs, True, True) diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py index 75cd738b6..5ab96274f 100644 --- a/tests/unit_tests/test_help_inspector.py +++ b/tests/unit_tests/test_help_inspector.py @@ -1,9 +1,12 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +from unittest import mock + import pytest from qtpy import QtCore, QtWidgets from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from .client_mocks import mocked_client @@ -79,3 +82,51 @@ def test_help_inspector_escape_key(qtbot, help_inspector): assert not help_inspector._active assert not help_inspector._button.isChecked() assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_event_filter(help_inspector, abort_button): + """Test the event filter of the HelpInspector.""" + # Test nothing happens when not active + obj = mock.MagicMock(spec=QtWidgets.QWidget) + event = mock.MagicMock(spec=QtCore.QEvent) + assert help_inspector._active is False + with mock.patch.object( + QtWidgets.QWidget, "eventFilter", return_value=False + ) as super_event_filter: + help_inspector.eventFilter(obj, event) # should do nothing and return False + super_event_filter.assert_called_once_with(obj, event) + super_event_filter.reset_mock() + + help_inspector._active = True + with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle: + # Key press Escape + event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress) + event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape) + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + + # Click on itself + event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress) + event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton) + event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1)) + with mock.patch.object( + help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button] + ): + # Return for self call + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + # Run Callback for abort_button + callback_data = [] + + def _my_callback(widget): + callback_data.append(widget) + + help_inspector.register_callback(_my_callback) + + help_inspector.eventFilter(obj, event) + mock_toggle.assert_not_called() + assert len(callback_data) == 1 + assert callback_data[0] == abort_button + callback_data.clear() From b0cb048c8199b313e7d449157155318902859e74 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 17 Oct 2025 13:55:00 +0200 Subject: [PATCH 020/155] feat(ads): add pyi stub file to provide type hints for ads --- .../widgets/containers/ads/__init__.py | 1 + .../widgets/containers/ads/__init__.pyi | 1039 +++++++++++++++++ 2 files changed, 1040 insertions(+) create mode 100644 bec_widgets/widgets/containers/ads/__init__.py create mode 100644 bec_widgets/widgets/containers/ads/__init__.pyi diff --git a/bec_widgets/widgets/containers/ads/__init__.py b/bec_widgets/widgets/containers/ads/__init__.py new file mode 100644 index 000000000..aa837994c --- /dev/null +++ b/bec_widgets/widgets/containers/ads/__init__.py @@ -0,0 +1 @@ +from PySide6QtAds import * diff --git a/bec_widgets/widgets/containers/ads/__init__.pyi b/bec_widgets/widgets/containers/ads/__init__.pyi new file mode 100644 index 000000000..7bb78f08b --- /dev/null +++ b/bec_widgets/widgets/containers/ads/__init__.pyi @@ -0,0 +1,1039 @@ +from __future__ import annotations + +import collections +import enum +import typing + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Signal + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class SideBarLocation(enum.Enum): + SideBarTop = ... + SideBarLeft = ... + SideBarRight = ... + SideBarBottom = ... + SideBarNone = ... + +class eBitwiseOperator(enum.Enum): + BitwiseAnd = ... + BitwiseOr = ... + +class eIcon(enum.Enum): + TabCloseIcon = ... + AutoHideIcon = ... + DockAreaMenuIcon = ... + DockAreaUndockIcon = ... + DockAreaCloseIcon = ... + DockAreaMinimizeIcon = ... + IconCount = ... + +class eDragState(enum.Enum): + DraggingInactive = ... + DraggingMousePressed = ... + DraggingTab = ... + DraggingFloatingWidget = ... + +class TitleBarButton(enum.Enum): + TitleBarButtonTabsMenu = ... + TitleBarButtonUndock = ... + TitleBarButtonClose = ... + TitleBarButtonAutoHide = ... + TitleBarButtonMinimize = ... + +class eTabIndex(enum.Enum): + TabDefaultInsertIndex = ... + TabInvalidIndex = ... + +class DockWidgetArea(enum.Enum): + NoDockWidgetArea = ... + LeftDockWidgetArea = ... + RightDockWidgetArea = ... + TopDockWidgetArea = ... + BottomDockWidgetArea = ... + CenterDockWidgetArea = ... + LeftAutoHideArea = ... + RightAutoHideArea = ... + TopAutoHideArea = ... + BottomAutoHideArea = ... + InvalidDockWidgetArea = ... + OuterDockAreas = ... + AutoHideDockAreas = ... + AllDockAreas = ... + +class CAutoHideDockContainer(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + area: SideBarLocation, + parent: typing.Optional["CDockContainerWidget"], + ) -> None: ... + def moveToNewSideBarLocation(self, a0: SideBarLocation) -> None: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def resetToInitialDockWidgetSize(self) -> None: ... + def setSize(self, Size: int) -> None: ... + def toggleCollapseState(self) -> None: ... + def collapseView(self, Enable: bool) -> None: ... + def toggleView(self, Enable: bool) -> None: ... + def cleanupAndDelete(self) -> None: ... + def moveContentsToParent(self) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setSideBarLocation(self, SideBarLocation: SideBarLocation) -> None: ... + def sideBarLocation(self) -> SideBarLocation: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def tabIndex(self) -> int: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def autoHideTab(self) -> typing.Optional["CAutoHideTab"]: ... + def autoHideSideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def updateSize(self) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def leaveEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CAutoHideSideBar(QtWidgets.QScrollArea): + def __init__( + self, parent: typing.Optional["CDockContainerWidget"], area: "SideBarLocation" + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def setSpacing(self, Spacing: int) -> None: ... + def spacing(self) -> int: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def hasVisibleTabs(self) -> bool: ... + def visibleTabCount(self) -> int: ... + def count(self) -> int: ... + def indexOfTab(self, Tab: "CAutoHideTab") -> int: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, index: int) -> typing.Optional["CAutoHideTab"]: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def addAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"], Index: int + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def insertDockWidget( + self, Index: int, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def removeTab(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def insertTab(self, Index: int, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CPushButton(QtWidgets.QPushButton): + class Orientation(enum.Enum): + Horizontal = ... + VerticalTopToBottom = ... + VerticalBottomToTop = ... + + def __init__(self) -> None: ... + def setButtonOrientation(self, orientation: "CPushButton.Orientation") -> None: ... + def buttonOrientation(self) -> "CPushButton.Orientation": ... + def sizeHint(self) -> QtCore.QSize: ... + +class CAutoHideTab(CPushButton): + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def unpinDockWidget(self) -> None: ... + def setDockWidgetFloating(self) -> None: ... + def tabIndex(self) -> int: ... + def sideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def iconOnly(self) -> bool: ... + def setDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def isActiveTab(self) -> bool: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def setOrientation(self, Orientation: QtCore.Qt.Orientation) -> None: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def updateStyle(self) -> None: ... + def dragLeaveEvent(self, ev: typing.Optional[QtGui.QDragLeaveEvent]) -> None: ... + def dragEnterEvent(self, ev: typing.Optional[QtGui.QDragEnterEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def removeFromSideBar(self) -> None: ... + def setSideBar(self, SideTabBar: typing.Optional["CAutoHideSideBar"]) -> None: ... + +class CDockWidget(QtWidgets.QFrame): + class eToggleViewActionMode(enum.Enum): + ActionModeToggle = ... + ActionModeShow = ... + + class eMinimumSizeHintMode(enum.Enum): + MinimumSizeHintFromDockWidget = ... + MinimumSizeHintFromContent = ... + MinimumSizeHintFromDockWidgetMinimumSize = ... + MinimumSizeHintFromContentMinimumSize = ... + + class eInsertMode(enum.Enum): + AutoScrollArea = ... + ForceScrollArea = ... + ForceNoScrollArea = ... + + class eToolBarStyleSource(enum.Enum): + ToolBarStyleFromDockManager = ... + ToolBarStyleFromDockWidget = ... + + class eState(enum.Enum): + StateHidden = ... + StateDocked = ... + StateFloating = ... + + class DockWidgetFeature(enum.Enum): + DockWidgetClosable = ... + DockWidgetMovable = ... + DockWidgetFloatable = ... + DockWidgetDeleteOnClose = ... + CustomCloseHandling = ... + DockWidgetFocusable = ... + DockWidgetForceCloseWithArea = ... + NoTab = ... + DeleteContentOnClose = ... + DockWidgetPinnable = ... + DefaultDockWidgetFeatures = ... + AllDockWidgetFeatures = ... + DockWidgetAlwaysCloseAndDelete = ... + GloballyLockableFeatures = ... + NoDockWidgetFeatures = ... + + @typing.overload + def __init__( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + @typing.overload + def __init__( + self, + manager: typing.Optional["CDockManager"], + title: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + featuresChanged: typing.ClassVar[Signal] + visibilityChanged: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + topLevelChanged: typing.ClassVar[Signal] + titleChanged: typing.ClassVar[Signal] + closed: typing.ClassVar[Signal] + viewToggled: typing.ClassVar[Signal] + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def showNormal(self) -> None: ... + def showFullScreen(self) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def closeDockWidget(self) -> None: ... + def deleteDockWidget(self) -> None: ... + def setFloating(self) -> None: ... + def raise_(self) -> None: ... + def setAsCurrentTab(self) -> None: ... + def toggleView(self, Open: bool = ...) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isCurrentTab(self) -> bool: ... + def isTabbed(self) -> bool: ... + def isFullScreen(self) -> bool: ... + def setTabToolTip(self, text: typing.Optional[str]) -> None: ... + def titleBarActions(self) -> list[QtGui.QAction]: ... + def setTitleBarActions(self, actions: collections.abc.Iterable[QtGui.QAction]) -> None: ... + def toolBarIconSize(self, State: "CDockWidget.eState") -> QtCore.QSize: ... + def setToolBarIconSize(self, IconSize: QtCore.QSize, State: "CDockWidget.eState") -> None: ... + def toolBarStyle(self, State: "CDockWidget.eState") -> QtCore.Qt.ToolButtonStyle: ... + def setToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: "CDockWidget.eState" + ) -> None: ... + def toolBarStyleSource(self) -> "CDockWidget.eToolBarStyleSource": ... + def setToolBarStyleSource(self, Source: "CDockWidget.eToolBarStyleSource") -> None: ... + def setToolBar(self, ToolBar: typing.Optional[QtWidgets.QToolBar]) -> None: ... + def createDefaultToolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def toolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def isCentralWidget(self) -> bool: ... + def minimumSizeHintMode(self) -> "CDockWidget.eMinimumSizeHintMode": ... + def setMinimumSizeHintMode(self, Mode: "CDockWidget.eMinimumSizeHintMode") -> None: ... + def setToggleViewActionMode(self, Mode: "CDockWidget.eToggleViewActionMode") -> None: ... + def setToggleViewAction(self, action: typing.Optional[QtGui.QAction]) -> None: ... + def toggleViewAction(self) -> typing.Optional[QtGui.QAction]: ... + def isClosed(self) -> bool: ... + def isInFloatingContainer(self) -> bool: ... + def isFloating(self) -> bool: ... + def autoHideLocation(self) -> "SideBarLocation": ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def isAutoHide(self) -> bool: ... + def setSideTabWidget(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def sideTabWidget(self) -> typing.Optional["CAutoHideTab"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def floatingDockContainer(self) -> typing.Optional["CFloatingDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def setFeature(self, flag: "CDockWidget.DockWidgetFeature", on: bool) -> None: ... + def setFeatures(self, features: "CDockWidget.DockWidgetFeature") -> None: ... + def tabWidget(self) -> typing.Optional["CDockWidgetTab"]: ... + def widget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def takeWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def setWidget( + self, + widget: typing.Optional[QtWidgets.QWidget], + InsertMode: "CDockWidget.eInsertMode" = ..., + ) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def closeDockWidgetInternal(self, ForceClose: bool = ...) -> bool: ... + def toggleViewInternal(self, Open: bool) -> None: ... + def setClosedState(self, Closed: bool) -> None: ... + def emitTopLevelChanged(self, Floating: bool) -> None: ... + @staticmethod + def emitTopLevelEventForWidget( + TopLevelDockWidget: typing.Optional["CDockWidget"], Floating: bool + ) -> None: ... + def flagAsUnassigned(self) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setToggleViewActionChecked(self, Checked: bool) -> None: ... + def setDockArea(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setDockManager(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + +class CDockAreaTabBar(QtWidgets.QScrollArea): + def __init__(self, parent: typing.Optional["CDockAreaWidget"]) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + tabInserted: typing.ClassVar[Signal] + removingTab: typing.ClassVar[Signal] + tabMoved: typing.ClassVar[Signal] + tabOpened: typing.ClassVar[Signal] + tabClosed: typing.ClassVar[Signal] + tabCloseRequested: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + def closeTab(self, Index: int) -> None: ... + def setCurrentIndex(self, Index: int) -> None: ... + def areTabsOverflowing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isTabOpen(self, Index: int) -> bool: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, Index: int) -> typing.Optional["CDockWidgetTab"]: ... + def currentTab(self) -> typing.Optional["CDockWidgetTab"]: ... + def currentIndex(self) -> int: ... + def count(self) -> int: ... + def removeTab(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def insertTab(self, Index: int, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def wheelEvent(self, Event: typing.Optional[QtGui.QWheelEvent]) -> None: ... + +class CSpacerWidget(QtWidgets.QWidget): + def __init__(self, Parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sizeHint(self) -> QtCore.QSize: ... + +class CTitleBarButton(QtWidgets.QToolButton): + def __init__( + self, + ShowInTitleBar: bool, + HideWhenDisabled: bool, + ButtonId: "TitleBarButton", + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + def event(self, ev: typing.Optional[QtCore.QEvent]) -> bool: ... + def isInAutoHideArea(self) -> bool: ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def buttonId(self) -> "TitleBarButton": ... + def setShowInTitleBar(self, a0: bool) -> None: ... + def setVisible(self, a0: bool) -> None: ... + +class CDockAreaTitleBar(QtWidgets.QFrame): + def __init__(self, parent: typing.Optional[CDockAreaWidget]) -> None: ... + + tabBarClicked: typing.ClassVar[Signal] + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def isAutoHide(self) -> bool: ... + def showAutoHideControls(self, Show: bool) -> None: ... + def setAreaFloating(self) -> None: ... + def titleBarButtonToolTip(self, Button: "TitleBarButton") -> str: ... + def indexOf(self, widget: typing.Optional[QtWidgets.QWidget]) -> int: ... + def insertWidget(self, index: int, widget: typing.Optional[QtWidgets.QWidget]) -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def updateDockWidgetActionsButtons(self) -> None: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def autoHideTitleLabel(self) -> typing.Optional["CElidingLabel"]: ... + def button(self, which: "TitleBarButton") -> typing.Optional["CTitleBarButton"]: ... + def tabBar(self) -> typing.Optional["CDockAreaTabBar"]: ... + def markTabsMenuOutdated(self) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def contextMenuEvent(self, event: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CDockAreaWidget(QtWidgets.QFrame): + class eDockAreaFlag(enum.Enum): + HideSingleWidgetTitleBar = ... + DefaultFlags = ... + + def __init__( + self, + DockManager: typing.Optional[CDockManager], + parent: typing.Optional[CDockContainerWidget], + ) -> None: ... + + viewToggled: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + def setFloating(self) -> None: ... + def closeOtherAreas(self) -> None: ... + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def closeArea(self) -> None: ... + def setCurrentIndex(self, index: int) -> None: ... + def isTopLevelArea(self) -> bool: ... + def containsCentralWidget(self) -> bool: ... + def isCentralWidgetArea(self) -> bool: ... + def setDockAreaFlag(self, Flag: "CDockAreaWidget.eDockAreaFlag", On: bool) -> None: ... + def setDockAreaFlags(self, Flags: "CDockAreaWidget.eDockAreaFlag") -> None: ... + def dockAreaFlags(self) -> "CDockAreaWidget.eDockAreaFlag": ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def titleBarButton( + self, which: "TitleBarButton" + ) -> typing.Optional[QtWidgets.QAbstractButton]: ... + def features(self, Mode: "eBitwiseOperator" = ...) -> "CDockWidget.DockWidgetFeature": ... + @staticmethod + def restoreState( + Stream: "CDockingStateReader", + Testing: bool, + ParentContainer: typing.Optional["CDockContainerWidget"], + ) -> typing.Tuple[bool, typing.Optional["CDockAreaWidget"]]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def currentDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def indexOfFirstOpenDockWidget(self) -> int: ... + def currentIndex(self) -> int: ... + def dockWidget(self, Index: int) -> typing.Optional["CDockWidget"]: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openDockWidgetsCount(self) -> int: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def dockWidgetsCount(self) -> int: ... + def contentAreaGeometry(self) -> QtCore.QRect: ... + def titleBarGeometry(self) -> QtCore.QRect: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def setAutoHideDockContainer(self, a0: typing.Optional["CAutoHideDockContainer"]) -> None: ... + def isAutoHide(self) -> bool: ... + def parentSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def toggleView(self, Open: bool) -> None: ... + def updateTitleBarButtonVisibility(self, IsTopLevel: bool) -> None: ... + def markTitleBarMenuOutdated(self) -> None: ... + def internalSetCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def updateTitleBarVisibility(self) -> None: ... + def hideAreaWithNoVisibleContent(self) -> None: ... + def index(self, DockWidget: typing.Optional["CDockWidget"]) -> int: ... + def nextOpenDockWidget( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockWidget"]: ... + def toggleDockWidgetView( + self, DockWidget: typing.Optional["CDockWidget"], Open: bool + ) -> None: ... + def removeDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def insertDockWidget( + self, index: int, DockWidget: typing.Optional["CDockWidget"], Activate: bool = ... + ) -> None: ... + +class CDockContainerWidget(QtWidgets.QFrame): + def __init__( + self, + DockManager: typing.Optional["CDockManager"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + dockAreaViewToggled: typing.ClassVar[Signal] + dockAreasRemoved: typing.ClassVar[Signal] + autoHideWidgetCreated: typing.ClassVar[Signal] + dockAreasAdded: typing.ClassVar[Signal] + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def contentRectGlobal(self) -> QtCore.QRect: ... + def contentRect(self) -> QtCore.QRect: ... + def autoHideWidgets(self) -> list["CAutoHideDockContainer"]: ... + def autoHideSideBar(self, area: "SideBarLocation") -> typing.Optional["CAutoHideSideBar"]: ... + def closeOtherAreas(self, KeepOpenArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def floatingWidget(self) -> typing.Optional["CFloatingDockContainer"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def dumpLayout(self) -> None: ... + def isFloating(self) -> bool: ... + def visibleDockAreaCount(self) -> int: ... + def dockAreaCount(self) -> int: ... + def hasTopLevelDockWidget(self) -> bool: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openedDockAreas(self) -> list["CDockAreaWidget"]: ... + def dockArea(self, Index: int) -> typing.Optional["CDockAreaWidget"]: ... + def dockAreaAt(self, GlobalPos: QtCore.QPoint) -> typing.Optional["CDockAreaWidget"]: ... + def isInFrontOf(self, Other: typing.Optional["CDockContainerWidget"]) -> bool: ... + def zOrderIndex(self) -> int: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def handleAutoHideWidgetEvent( + self, e: typing.Optional[QtCore.QEvent], w: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def registerAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def updateSplitterHandles(self, splitter: typing.Optional[QtWidgets.QSplitter]) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockArea(self) -> typing.Optional["CDockAreaWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def lastAddedDockAreaWidget( + self, area: "DockWidgetArea" + ) -> typing.Optional["CDockAreaWidget"]: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def removeAllDockAreas(self) -> list[CDockAreaWidget]: ... + def removeDockArea(self, area: typing.Optional["CDockAreaWidget"]) -> None: ... + def addDockArea( + self, DockAreaWidget: typing.Optional["CDockAreaWidget"], area: "DockWidgetArea" = ... + ) -> None: ... + def dropWidget( + self, + Widget: typing.Optional[QtWidgets.QWidget], + DropArea: "DockWidgetArea", + TargetAreaWidget: typing.Optional["CDockAreaWidget"], + TabIndex: int = ..., + ) -> None: ... + def dropFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"], TargetPos: QtCore.QPoint + ) -> None: ... + def createRootSplitter(self) -> None: ... + def createAndSetupAutoHideContainer( + self, + area: "SideBarLocation", + DockWidget: typing.Optional["CDockWidget"], + TabIndex: int = ..., + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def rootSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + +class CDockingStateReader(QtCore.QXmlStreamReader): + def __init__(self) -> None: ... + def fileVersion(self) -> int: ... + def setFileVersion(self, FileVersion: int) -> None: ... + +class CDockFocusController(QtCore.QObject): + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + def setDockWidgetFocused(self, focusedNow: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabPressed(self, Value: bool) -> None: ... + def clearDockWidgetFocus(self, dockWidget: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabFocused(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + +class CDockManager(CDockContainerWidget): + class eConfigParam(enum.Enum): + AutoHideOpenOnDragHoverDelay_ms = ... + ConfigParamCount = ... + + class eAutoHideFlag(enum.Enum): + AutoHideFeatureEnabled = ... + DockAreaHasAutoHideButton = ... + AutoHideButtonTogglesArea = ... + AutoHideButtonCheckable = ... + AutoHideSideBarsIconOnly = ... + AutoHideShowOnMouseOver = ... + AutoHideCloseButtonCollapsesDock = ... + AutoHideHasCloseButton = ... + AutoHideHasMinimizeButton = ... + AutoHideOpenOnDragHover = ... + AutoHideCloseOnOutsideMouseClick = ... + DefaultAutoHideConfig = ... + + class eConfigFlag(enum.Enum): + ActiveTabHasCloseButton = ... + DockAreaHasCloseButton = ... + DockAreaCloseButtonClosesTab = ... + OpaqueSplitterResize = ... + XmlAutoFormattingEnabled = ... + XmlCompressionEnabled = ... + TabCloseButtonIsToolButton = ... + AllTabsHaveCloseButton = ... + RetainTabSizeWhenCloseButtonHidden = ... + DragPreviewIsDynamic = ... + DragPreviewShowsContentPixmap = ... + DragPreviewHasWindowFrame = ... + AlwaysShowTabs = ... + DockAreaHasUndockButton = ... + DockAreaHasTabsMenuButton = ... + DockAreaHideDisabledButtons = ... + DockAreaDynamicTabsMenuButtonVisibility = ... + FloatingContainerHasWidgetTitle = ... + FloatingContainerHasWidgetIcon = ... + HideSingleCentralWidgetTitleBar = ... + FocusHighlighting = ... + EqualSplitOnInsertion = ... + FloatingContainerForceNativeTitleBar = ... + FloatingContainerForceQWidgetTitleBar = ... + MiddleMouseButtonClosesTab = ... + DisableTabTextEliding = ... + ShowTabTextOnlyForActiveTab = ... + DoubleClickUndocksWidget = ... + DefaultDockAreaButtons = ... + DefaultBaseConfig = ... + DefaultOpaqueConfig = ... + DefaultNonOpaqueConfig = ... + NonOpaqueWithWindowFrame = ... + + class eViewMenuInsertionOrder(enum.Enum): + MenuSortedByInsertion = ... + MenuAlphabeticallySorted = ... + + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + + focusedDockWidgetChanged: typing.ClassVar[Signal] + dockWidgetRemoved: typing.ClassVar[Signal] + dockWidgetAboutToBeRemoved: typing.ClassVar[Signal] + dockWidgetAdded: typing.ClassVar[Signal] + dockAreaCreated: typing.ClassVar[Signal] + floatingWidgetCreated: typing.ClassVar[Signal] + perspectiveOpened: typing.ClassVar[Signal] + openingPerspective: typing.ClassVar[Signal] + stateRestored: typing.ClassVar[Signal] + restoringState: typing.ClassVar[Signal] + perspectivesRemoved: typing.ClassVar[Signal] + perspectiveListLoaded: typing.ClassVar[Signal] + perspectiveListChanged: typing.ClassVar[Signal] + def hideManagerAndFloatingWidgets(self) -> None: ... + def setDockWidgetFocused(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def openPerspective(self, PerspectiveName: typing.Optional[str]) -> None: ... + def endLeavingMinimizedState(self) -> None: ... + def lockDockWidgetFeaturesGlobally( + self, Features: CDockWidget.DockWidgetFeature = ... + ) -> None: ... + def globallyLockedDockWidgetFeatures(self) -> CDockWidget.DockWidgetFeature: ... + def dockWidgetToolBarIconSize(self, State: CDockWidget.eState) -> QtCore.QSize: ... + def setDockWidgetToolBarIconSize( + self, IconSize: QtCore.QSize, State: CDockWidget.eState + ) -> None: ... + def dockWidgetToolBarStyle(self, State: CDockWidget.eState) -> QtCore.Qt.ToolButtonStyle: ... + def setDockWidgetToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: CDockWidget.eState + ) -> None: ... + @staticmethod + def floatingContainersTitle() -> str: ... + @staticmethod + def setFloatingContainersTitle(Title: typing.Optional[str]) -> None: ... + def setSplitterSizes( + self, + ContainedArea: typing.Optional["CDockAreaWidget"], + sizes: collections.abc.Iterable[int], + ) -> None: ... + def splitterSizes(self, ContainedArea: typing.Optional["CDockAreaWidget"]) -> list[int]: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + @staticmethod + def startDragDistance() -> int: ... + def isLeavingMinimizedState(self) -> bool: ... + def isRestoringState(self) -> bool: ... + def setViewMenuInsertionOrder(self, Order: "CDockManager.eViewMenuInsertionOrder") -> None: ... + def viewMenu(self) -> typing.Optional[QtWidgets.QMenu]: ... + def addToggleViewActionToMenu( + self, + ToggleViewAction: typing.Optional[QtGui.QAction], + Group: typing.Optional[str] = ..., + GroupIcon: QtGui.QIcon = ..., + ) -> typing.Optional[QtGui.QAction]: ... + def setCentralWidget( + self, widget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def centralWidget(self) -> typing.Optional["CDockWidget"]: ... + def loadPerspectives(self, Settings: QtCore.QSettings) -> None: ... + def savePerspectives(self, Settings: QtCore.QSettings) -> None: ... + def perspectiveNames(self) -> list[str]: ... + def removePerspectives(self, Names: collections.abc.Iterable[typing.Optional[str]]) -> None: ... + def removePerspective(self, Name: typing.Optional[str]) -> None: ... + def addPerspective(self, UniquePrespectiveName: typing.Optional[str]) -> None: ... + def restoreState( + self, + state: typing.Union[QtCore.QByteArray, bytes, bytearray, memoryview], + version: int = ..., + ) -> bool: ... + def saveState(self, version: int = ...) -> QtCore.QByteArray: ... + def zOrderIndex(self) -> int: ... + def floatingWidgets(self) -> list["CFloatingDockContainer"]: ... + def dockContainers(self) -> list["CDockContainerWidget"]: ... + def dockWidgetsMap(self) -> dict[str, CDockWidget]: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def findDockWidget( + self, ObjectName: typing.Optional[str] + ) -> typing.Optional["CDockWidget"]: ... + def addDockWidgetFloating( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CFloatingDockContainer"]: ... + def addDockWidgetTabToArea( + self, + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"], + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidgetTab( + self, area: "DockWidgetArea", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def addAutoHideDockWidgetToContainer( + self, + Location: "SideBarLocation", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"], + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addAutoHideDockWidget( + self, Location: "SideBarLocation", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addDockWidgetToContainer( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"] = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + @staticmethod + def iconProvider() -> "CIconProvider": ... + @staticmethod + def configParam(Param: "CDockManager.eConfigParam", Default: typing.Any) -> typing.Any: ... + @staticmethod + def setConfigParam(Param: "CDockManager.eConfigParam", Value: typing.Any) -> None: ... + @staticmethod + def testAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag") -> bool: ... + @staticmethod + def setAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag", On: bool = ...) -> None: ... + @staticmethod + def setAutoHideConfigFlags(Flags: "CDockManager.eAutoHideFlag") -> None: ... + @staticmethod + def autoHideConfigFlags() -> "CDockManager.eAutoHideFlag": ... + @staticmethod + def testConfigFlag(Flag: "CDockManager.eConfigFlag") -> bool: ... + @staticmethod + def setConfigFlag(Flag: "CDockManager.eConfigFlag", On: bool = ...) -> None: ... + @staticmethod + def setConfigFlags(Flags: "CDockManager.eConfigFlag") -> None: ... + @staticmethod + def configFlags() -> "CDockManager.eConfigFlag": ... + def setComponentsFactory(self, Factory: typing.Optional["CDockComponentsFactory"]) -> None: ... + def componentsFactory(self) -> typing.Optional["CDockComponentsFactory"]: ... + def createDockWidget( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> typing.Optional["CDockWidget"]: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def dockAreaOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def containerOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def removeDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def registerDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def removeFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def registerFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + +class CDockOverlay(QtWidgets.QFrame): + class eMode(enum.Enum): + ModeDockAreaOverlay = ... + ModeContainerOverlay = ... + + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget], Mode: "CDockOverlay.eMode" = ... + ) -> None: ... + def hideEvent(self, e: typing.Optional[QtGui.QHideEvent]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def dropOverlayRect(self) -> QtCore.QRect: ... + def dropPreviewEnabled(self) -> bool: ... + def enableDropPreview(self, Enable: bool) -> None: ... + def hideOverlay(self) -> None: ... + def showOverlay(self, target: typing.Optional[QtWidgets.QWidget]) -> "DockWidgetArea": ... + def visibleDropAreaUnderCursor(self) -> "DockWidgetArea": ... + def tabIndexUnderCursor(self) -> int: ... + def dropAreaUnderCursor(self) -> "DockWidgetArea": ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedArea(self, area: "DockWidgetArea", Enable: bool) -> None: ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + +class CDockOverlayCross(QtWidgets.QWidget): + class eIconColor(enum.Enum): + FrameColor = ... + WindowBackgroundColor = ... + OverlayColor = ... + ArrowColor = ... + ShadowColor = ... + + def __init__(self, overlay: typing.Optional["CDockOverlay"]) -> None: ... + def setIconColors(self, Colors: typing.Optional[str]) -> None: ... + def updatePosition(self) -> None: ... + def reset(self) -> None: ... + def updateOverlayIcons(self) -> None: ... + def setupOverlayCross(self, Mode: "CDockOverlay.eMode") -> None: ... + def cursorLocation(self) -> "DockWidgetArea": ... + def setIconColor( + self, + ColorIndex: "CDockOverlayCross.eIconColor", + Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int], + ) -> None: ... + def setAreaWidgets(self, widgets: dict["DockWidgetArea", QtWidgets.QWidget]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def setIconShadowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconArrowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconOverlayColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconBackgroundColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconFrameColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + @typing.overload + def iconColor(self) -> QtGui.QColor: ... + @typing.overload + def iconColor(self, ColorIndex: "CDockOverlayCross.eIconColor") -> QtGui.QColor: ... + def iconColors(self) -> str: ... + +class CDockSplitter(QtWidgets.QSplitter): + @typing.overload + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + @typing.overload + def __init__( + self, orientation: QtCore.Qt.Orientation, parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + def isResizingWithContainer(self) -> bool: ... + def lastWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def firstWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def hasVisibleContent(self) -> bool: ... + +class CDockWidgetTab(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + moved: typing.ClassVar[Signal] + closeOtherTabsRequested: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + activeTabChanged: typing.ClassVar[Signal] + def setVisible(self, visible: bool) -> None: ... + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def dragState(self) -> "eDragState": ... + def setIconSize(self, Size: QtCore.QSize) -> None: ... + def iconSize(self) -> QtCore.QSize: ... + def updateStyle(self) -> None: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isClosable(self) -> bool: ... + def isTitleElided(self) -> bool: ... + def setText(self, title: typing.Optional[str]) -> None: ... + def text(self) -> str: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setDockAreaWidget(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setActiveTab(self, active: bool) -> None: ... + def isActiveTab(self) -> bool: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CElidingLabel(QtWidgets.QLabel): + @typing.overload + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget] = ..., f: QtCore.Qt.WindowType = ... + ) -> None: ... + @typing.overload + def __init__( + self, + text: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + f: QtCore.Qt.WindowType = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + doubleClicked: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + def text(self) -> str: ... + def setText(self, text: typing.Optional[str]) -> None: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isElided(self) -> bool: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def elideMode(self) -> QtCore.Qt.TextElideMode: ... + def mouseDoubleClickEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def mouseReleaseEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class IFloatingWidget: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: "IFloatingWidget") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDockContainer(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + @typing.overload + def __init__(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + @typing.overload + def __init__(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def finishDropOperation(self) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def hasTopLevelDockWidget(self) -> bool: ... + def isClosable(self) -> bool: ... + def startDragging( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def moveEvent(self, event: typing.Optional[QtGui.QMoveEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def hideEvent(self, event: typing.Optional[QtGui.QHideEvent]) -> None: ... + def closeEvent(self, event: typing.Optional[QtGui.QCloseEvent]) -> None: ... + def changeEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def updateWindowTitle(self) -> None: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def moveFloating(self) -> None: ... + def initFloatingGeometry( + self, DragStartMousePos: QtCore.QPoint, Size: QtCore.QSize + ) -> None: ... + def deleteContent(self) -> None: ... + def finishDragging(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDragPreview(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__( + self, + Content: typing.Optional[QtWidgets.QWidget], + parent: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockWidget"]) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockAreaWidget"]) -> None: ... + + draggingCanceled: typing.ClassVar[Signal] + def cleanupAutoHideContainerWidget(self, ContainerDropArea: "DockWidgetArea") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + +class CIconProvider: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: CIconProvider) -> None: ... + def registerCustomIcon(self, IconId: "eIcon", icon: QtGui.QIcon) -> None: ... + def customIcon(self, IconId: "eIcon") -> QtGui.QIcon: ... + +class CResizeHandle(QtWidgets.QFrame): + def __init__( + self, HandlePosition: QtCore.Qt.Edge, parent: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def opaqueResize(self) -> bool: ... + def setOpaqueResize(self, opaque: bool = ...) -> None: ... + def setMaxResizeSize(self, MaxSize: int) -> None: ... + def setMinResizeSize(self, MinSize: int) -> None: ... + def isResizing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def handlePostion(self) -> QtCore.Qt.Edge: ... + def setHandlePosition(self, HandlePosition: QtCore.Qt.Edge) -> None: ... + def mouseReleaseEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... From b4987fe75947976df96ba1794e606ae8bd8b8fa4 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 17 Oct 2025 13:40:42 +0200 Subject: [PATCH 021/155] feat(jupyter_console_window): adjustment for general usage --- .../jupyter_console/jupyter_console_window.py | 477 +++++++++++++----- .../jupyter_console/jupyter_console.py | 49 +- 2 files changed, 391 insertions(+), 135 deletions(-) diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index b5e925633..ed0dc0356 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -1,12 +1,23 @@ +from __future__ import annotations + +import ast +import importlib import os +from typing import Any, Dict import numpy as np import pyqtgraph as pg from bec_qthemes import material_icon +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, + QComboBox, + QFrame, + QGridLayout, QGroupBox, QHBoxLayout, + QLabel, + QLineEdit, QPushButton, QSplitter, QTabWidget, @@ -14,148 +25,359 @@ QWidget, ) -from bec_widgets.utils import BECDispatcher +from bec_widgets import BECWidget +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea -from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole -from bec_widgets.widgets.plots.image.image import Image -from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap -from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform -from bec_widgets.widgets.plots.plot_base import PlotBase -from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform -from bec_widgets.widgets.plots.waveform.waveform import Waveform class JupyterConsoleWindow(QWidget): # pragma: no cover: - """A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API).""" + """A widget that contains a Jupyter console linked to BEC Widgets with full API access. + + Features: + - Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`. + - Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`. + - Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut. + - Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block. + """ - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self._widgets_by_name: Dict[str, QWidget] = {} self._init_ui() - # console push + # expose helper API and basics in the inprocess console if self.console.inprocess is True: - self.console.kernel_manager.kernel.shell.push( - { - "np": np, - "pg": pg, - "wh": wh, - "dock": self.dock, - "im": self.im, - "ads": self.ads, - # "mi": self.mi, - # "mm": self.mm, - # "lm": self.lm, - # "btn1": self.btn1, - # "btn2": self.btn2, - # "btn3": self.btn3, - # "btn4": self.btn4, - # "btn5": self.btn5, - # "btn6": self.btn6, - # "pb": self.pb, - # "pi": self.pi, - "wf": self.wf, - # "scatter": self.scatter, - # "scatter_mi": self.scatter, - # "mwf": self.mwf, - } - ) + # A thin API wrapper so users have a stable, minimal surface in the console + class _ConsoleAPI: + def __init__(self, win: "JupyterConsoleWindow"): + self._win = win + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None): + """Add an existing QWidget as a new tab and expose it in the console under `shortcut`.""" + return self._win.add_widget(widget, shortcut, title=title) + + def add_widget_by_class_path( + self, + class_path: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Import a QWidget class from `class_path`, instantiate it, and add it.""" + return self._win.add_widget_by_class_path( + class_path, shortcut, kwargs=kwargs, title=title + ) + + def add_widget_by_type( + self, + widget_type: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Instantiate a registered BEC widget by type string and add it.""" + return self._win.add_widget_by_type( + widget_type, shortcut, kwargs=kwargs, title=title + ) + + def list_widgets(self): + return list(self._win._widgets_by_name.keys()) + + def get_widget(self, shortcut: str) -> QWidget | None: + return self._win._widgets_by_name.get(shortcut) + + def available_widgets(self): + return list(widget_handler.widget_classes.keys()) + + self.jc = _ConsoleAPI(self) + self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh}) def _init_ui(self): self.layout = QHBoxLayout(self) - # Horizontal splitter + # Horizontal splitter: left = widgets tabs, right = console + add-widget panel splitter = QSplitter(self) self.layout.addWidget(splitter) - tab_widget = QTabWidget(splitter) - - first_tab = QWidget() - first_tab_layout = QVBoxLayout(first_tab) - self.dock = BECDockArea(gui_id="dock") - first_tab_layout.addWidget(self.dock) - tab_widget.addTab(first_tab, "Dock Area") - - # third_tab = QWidget() - # third_tab_layout = QVBoxLayout(third_tab) - # self.lm = LayoutManagerWidget() - # third_tab_layout.addWidget(self.lm) - # tab_widget.addTab(third_tab, "Layout Manager Widget") - # - # fourth_tab = QWidget() - # fourth_tab_layout = QVBoxLayout(fourth_tab) - # self.pb = PlotBase() - # self.pi = self.pb.plot_item - # fourth_tab_layout.addWidget(self.pb) - # tab_widget.addTab(fourth_tab, "PlotBase") - # - # tab_widget.setCurrentIndex(3) - # - group_box = QGroupBox("Jupyter Console", splitter) - group_box_layout = QVBoxLayout(group_box) + # Left: tabs that will host dynamically added widgets + self.tab_widget = QTabWidget(splitter) + + # Right: console area with an add-widget mini panel on top + right_panel = QGroupBox("Jupyter Console", splitter) + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(6, 12, 6, 6) + + # Add-widget mini panel + add_panel = QFrame(right_panel) + shape = QFrame.Shape.StyledPanel # PySide6 style enums + add_panel.setFrameShape(shape) + add_grid = QGridLayout(add_panel) + add_grid.setContentsMargins(8, 8, 8, 8) + add_grid.setHorizontalSpacing(8) + add_grid.setVerticalSpacing(6) + + instr = QLabel( + "Add a widget by class path or choose a registered BEC widget type," + " and expose it in the console under a shortcut.\n" + "Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform" + ) + instr.setWordWrap(True) + add_grid.addWidget(instr, 0, 0, 1, 2) + + # Registered widget selector + reg_label = QLabel("Registered") + reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.registry_combo = QComboBox(add_panel) + self.registry_combo.setEditable(False) + self.refresh_btn = QPushButton("Refresh") + reg_row = QHBoxLayout() + reg_row.addWidget(self.registry_combo) + reg_row.addWidget(self.refresh_btn) + add_grid.addWidget(reg_label, 1, 0) + add_grid.addLayout(reg_row, 1, 1) + + # Class path entry + class_label = QLabel("Class") + class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.class_path_edit = QLineEdit(add_panel) + self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)") + add_grid.addWidget(class_label, 2, 0) + add_grid.addWidget(self.class_path_edit, 2, 1) + + # Shortcut + shortcut_label = QLabel("Shortcut") + shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.shortcut_edit = QLineEdit(add_panel) + self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)") + add_grid.addWidget(shortcut_label, 3, 0) + add_grid.addWidget(self.shortcut_edit, 3, 1) + + # Kwargs + kwargs_label = QLabel("Kwargs") + kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.kwargs_edit = QLineEdit(add_panel) + self.kwargs_edit.setPlaceholderText( + 'Optional kwargs as dict literal, e.g. {"popups": True}' + ) + add_grid.addWidget(kwargs_label, 4, 0) + add_grid.addWidget(self.kwargs_edit, 4, 1) + + # Title + title_label = QLabel("Title") + title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.title_edit = QLineEdit(add_panel) + self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)") + add_grid.addWidget(title_label, 5, 0) + add_grid.addWidget(self.title_edit, 5, 1) + + # Buttons + btn_row = QHBoxLayout() + self.add_btn = QPushButton("Add by class path") + self.add_btn.clicked.connect(self._on_add_widget_clicked) + self.add_reg_btn = QPushButton("Add registered") + self.add_reg_btn.clicked.connect(self._on_add_registered_clicked) + btn_row.addStretch(1) + btn_row.addWidget(self.add_reg_btn) + btn_row.addWidget(self.add_btn) + add_grid.addLayout(btn_row, 6, 0, 1, 2) + + # Make the second column expand + add_grid.setColumnStretch(0, 0) + add_grid.setColumnStretch(1, 1) + + # Console widget self.console = BECJupyterConsole(inprocess=True) - group_box_layout.addWidget(self.console) - # - # # Some buttons for layout testing - # self.btn1 = QPushButton("Button 1") - # self.btn2 = QPushButton("Button 2") - # self.btn3 = QPushButton("Button 3") - # self.btn4 = QPushButton("Button 4") - # self.btn5 = QPushButton("Button 5") - # self.btn6 = QPushButton("Button 6") - # - fifth_tab = QWidget() - fifth_tab_layout = QVBoxLayout(fifth_tab) - self.wf = Waveform() - fifth_tab_layout.addWidget(self.wf) - tab_widget.addTab(fifth_tab, "Waveform Next Gen") - # - sixth_tab = QWidget() - sixth_tab_layout = QVBoxLayout(sixth_tab) - self.im = Image(popups=True) - self.mi = self.im.main_image - sixth_tab_layout.addWidget(self.im) - tab_widget.addTab(sixth_tab, "Image Next Gen") - tab_widget.setCurrentIndex(1) - # - seventh_tab = QWidget() - seventh_tab_layout = QVBoxLayout(seventh_tab) - self.ads = AdvancedDockArea(gui_id="ads") - seventh_tab_layout.addWidget(self.ads) - tab_widget.addTab(seventh_tab, "ADS") - tab_widget.setCurrentIndex(2) - # - # eighth_tab = QWidget() - # eighth_tab_layout = QVBoxLayout(eighth_tab) - # self.mm = MotorMap() - # eighth_tab_layout.addWidget(self.mm) - # tab_widget.addTab(eighth_tab, "Motor Map") - # tab_widget.setCurrentIndex(7) - # - # ninth_tab = QWidget() - # ninth_tab_layout = QVBoxLayout(ninth_tab) - # self.mwf = MultiWaveform() - # ninth_tab_layout.addWidget(self.mwf) - # tab_widget.addTab(ninth_tab, "MultiWaveform") - # tab_widget.setCurrentIndex(8) - # - # # add stuff to the new Waveform widget - # self._init_waveform() - # - # self.setWindowTitle("Jupyter Console Window") - - def _init_waveform(self): - self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel") - self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel") + + # Vertical splitter between add panel and console + right_splitter = QSplitter(Qt.Vertical, right_panel) + right_splitter.addWidget(add_panel) + right_splitter.addWidget(self.console) + right_splitter.setStretchFactor(0, 0) + right_splitter.setStretchFactor(1, 1) + right_splitter.setSizes([300, 600]) + + # Put splitter into the right group box + right_layout.addWidget(right_splitter) + + # Populate registry on startup + self._populate_registry_widgets() + + def _populate_registry_widgets(self): + try: + widget_handler.update_available_widgets() + items = sorted(widget_handler.widget_classes.keys()) + except Exception as exc: + print(f"Failed to load registered widgets: {exc}") + items = [] + self.registry_combo.clear() + self.registry_combo.addItems(items) + + def _on_add_widget_clicked(self): + class_path = self.class_path_edit.text().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not class_path or not shortcut: + print("Please provide both class path and shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + except Exception as exc: + print(f"Failed to instantiate {class_path}: {exc}") + return + + try: + self.add_widget(widget, shortcut, title=title) + except Exception as exc: + print(f"Failed to add widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _on_add_registered_clicked(self): + widget_type = self.registry_combo.currentText().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not widget_type or not shortcut: + print("Please select a registered widget and provide a shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title) + except Exception as exc: + print(f"Failed to add registered widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget: + module_path, _, class_name = class_path.rpartition(".") + if not module_path or not class_name: + raise ValueError("class_path must be of the form 'package.module.Class'") + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + if kwargs is None: + obj = cls() + else: + obj = cls(**kwargs) + if not isinstance(obj, QWidget): + raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}") + return obj + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget: + """Add a QWidget as a new tab and expose it in the Jupyter console. + + - widget: a QWidget instance to host in a new tab + - shortcut: variable name used in the console to access it + - title: optional tab title (defaults to shortcut or class name) + """ + if not isinstance(widget, QWidget): + raise TypeError("widget must be a QWidget instance") + if not shortcut or not shortcut.isidentifier(): + raise ValueError("shortcut must be a valid Python identifier") + if shortcut in self._widgets_by_name: + raise ValueError(f"A widget with shortcut '{shortcut}' already exists") + if self.console.inprocess is not True: + raise RuntimeError("Adding widgets and exposing them requires inprocess console") + + tab_title = title or shortcut or widget.__class__.__name__ + self.tab_widget.addTab(widget, tab_title) + self._widgets_by_name[shortcut] = widget + + # Expose in console under the given shortcut + self._push_to_console({shortcut: widget}) + return widget + + def add_widget_by_class_path( + self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + return self.add_widget(widget, shortcut, title=title) + + def add_widget_by_type( + self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + """Instantiate a registered BEC widget by its type string and add it as a tab. + + If kwargs does not contain `object_name`, it will default to the provided shortcut. + """ + # Ensure registry is loaded + widget_handler.update_available_widgets() + cls = widget_handler.widget_classes.get(widget_type) + if cls is None: + raise ValueError(f"Unknown registered widget type: {widget_type}") + + if kwargs is None: + kwargs = {"object_name": shortcut} + else: + kwargs = dict(kwargs) + kwargs.setdefault("object_name", shortcut) + + # Instantiate and add + widget = cls(**kwargs) + if not isinstance(widget, QWidget): + raise TypeError( + f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}" + ) + return self.add_widget(widget, shortcut, title=title) + + def _push_to_console(self, mapping: Dict[str, Any]): + """Push Python objects into the inprocess kernel user namespace.""" + if self.console.inprocess is True: + self.console.kernel_manager.kernel.shell.push(mapping) + else: + raise RuntimeError("Can only push variables when using inprocess kernel") def closeEvent(self, event): """Override to handle things when main window is closed.""" - self.dock.cleanup() - self.dock.close() + # clean up any widgets that might have custom cleanup + try: + # call cleanup on known containers if present + dock = self._widgets_by_name.get("dock") + if isinstance(dock, BECDockArea): + dock.cleanup() + dock.close() + except Exception: + pass + + # Ensure the embedded kernel and BEC client are shut down before window teardown + self.console.shutdown_kernel() self.console.close() super().closeEvent(event) @@ -175,13 +397,20 @@ def closeEvent(self, event): icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) app.setWindowIcon(icon) - bec_dispatcher = BECDispatcher(gui_id="jupyter_console") - client = bec_dispatcher.client - client.start() - win = JupyterConsoleWindow() + + # Examples: add two widgets programmatically to demonstrate usage + try: + win.add_widget_by_type("Waveform", shortcut="wf") + except Exception as exc: + print(f"Example add failed (Waveform by type): {exc}") + + try: + win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True}) + except Exception as exc: + print(f"Example add failed (Image by type): {exc}") + win.show() win.resize(1500, 800) - app.aboutToQuit.connect(win.close) sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py index 24234db64..e53226898 100644 --- a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py +++ b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py @@ -2,6 +2,7 @@ from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.manager import QtKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget +from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QMainWindow @@ -9,10 +10,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: def __init__(self, inprocess: bool = False): super().__init__() - self.inprocess = None - self.client = None + self.inprocess = inprocess + self.ipyclient = None - self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess) + self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess) self.set_default_style("linux") self._init_bec() @@ -35,14 +36,13 @@ def _init_bec(self): self._init_bec_kernel() def _init_bec_inprocess(self): - self.client = BECIPythonClient() - self.client.start() - + self.ipyclient = BECIPythonClient() + self.ipyclient.start() self.kernel_manager.kernel.shell.push( { - "bec": self.client, - "dev": self.client.device_manager.devices, - "scans": self.client.scans, + "bec": self.ipyclient, + "dev": self.ipyclient.device_manager.devices, + "scans": self.ipyclient.scans, } ) @@ -57,20 +57,47 @@ def _init_bec_kernel(self): """ ) + def _cleanup_bec(self): + if getattr(self, "ipyclient", None) is not None and self.inprocess is True: + self.ipyclient.shutdown() + self.ipyclient = None + def shutdown_kernel(self): + """ + Shutdown the Jupyter kernel and clean up resources. + """ + self._cleanup_bec() self.kernel_client.stop_channels() self.kernel_manager.shutdown_kernel() + self.kernel_client = None + self.kernel_manager = None def closeEvent(self, event): self.shutdown_kernel() + event.accept() + super().closeEvent(event) + + +class JupyterConsoleWindow(QMainWindow): # pragma: no cover: + def __init__(self, inprocess: bool = True, parent=None): + super().__init__(parent) + self.console = BECJupyterConsole(inprocess=inprocess) + self.setCentralWidget(self.console) + self.setAttribute(Qt.WA_DeleteOnClose, True) + + def closeEvent(self, event): + # Explicitly close the console so its own closeEvent runs + if getattr(self, "console", None) is not None: + self.console.close() + event.accept() + super().closeEvent(event) if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - win = QMainWindow() - win.setCentralWidget(BECJupyterConsole(True)) + win = JupyterConsoleWindow(inprocess=True) win.show() sys.exit(app.exec_()) From 01755aba07dda8b4bec0e83be91a9b2f4b787541 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 16:47:16 +0200 Subject: [PATCH 022/155] feat(developer_view): add developer view --- bec_widgets/applications/main_app.py | 27 +- .../views/developer_view/__init__.py | 0 .../views/developer_view/developer_view.py | 63 ++ .../views/developer_view/developer_widget.py | 347 +++++++++++ bec_widgets/applications/views/view.py | 103 +++- .../explorer/collapsible_tree_section.py | 23 +- .../widgets/containers/explorer/explorer.py | 4 +- .../containers/explorer/explorer_delegate.py | 125 ++++ .../containers/explorer/macro_tree_widget.py | 382 ++++++++++++ .../containers/explorer/script_tree_widget.py | 159 +---- .../widgets/editors/monaco/monaco_dock.py | 469 +++++++++++++++ .../widgets/editors/monaco/monaco_widget.py | 149 ++++- .../editors/monaco/scan_control_dialog.py | 145 +++++ .../utility/ide_explorer/ide_explorer.py | 263 ++++++++- .../ide_explorer/ide_explorer_plugin.py | 4 +- .../test_collapsible_tree_section.py | 119 ++++ tests/unit_tests/test_developer_view.py | 378 ++++++++++++ tests/unit_tests/test_ide_explorer.py | 422 ++++++++++++++ tests/unit_tests/test_macro_tree_widget.py | 548 ++++++++++++++++++ tests/unit_tests/test_monaco_dock.py | 425 ++++++++++++++ tests/unit_tests/test_monaco_editor.py | 85 ++- 21 files changed, 4073 insertions(+), 167 deletions(-) create mode 100644 bec_widgets/applications/views/developer_view/__init__.py create mode 100644 bec_widgets/applications/views/developer_view/developer_view.py create mode 100644 bec_widgets/applications/views/developer_view/developer_widget.py create mode 100644 bec_widgets/widgets/containers/explorer/explorer_delegate.py create mode 100644 bec_widgets/widgets/containers/explorer/macro_tree_widget.py create mode 100644 bec_widgets/widgets/editors/monaco/monaco_dock.py create mode 100644 bec_widgets/widgets/editors/monaco/scan_control_dialog.py create mode 100644 tests/unit_tests/test_collapsible_tree_section.py create mode 100644 tests/unit_tests/test_developer_view.py create mode 100644 tests/unit_tests/test_macro_tree_widget.py create mode 100644 tests/unit_tests/test_monaco_dock.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index da210c973..64a6b6053 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,6 +3,7 @@ from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.developer_view.developer_view import DeveloperView from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( DeviceManagerWidget, ) @@ -48,6 +49,7 @@ def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) self.device_manager = DeviceManagerWidget(self) + self.developer_view = DeveloperView(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" @@ -59,6 +61,13 @@ def _add_views(self): widget=self.device_manager, mini_text="DM", ) + self.add_view( + icon="code_blocks", + title="IDE", + widget=self.developer_view, + id="developer_view", + exclusive=True, + ) if self._show_examples: self.add_section("Examples", "examples") @@ -142,6 +151,8 @@ def add_view( # Wrap plain widgets into a ViewBase so enter/exit hooks are available if isinstance(widget, ViewBase): view_widget = widget + view_widget.view_id = id + view_widget.view_title = title else: view_widget = ViewBase(content=widget, parent=self, id=id, title=title) @@ -195,7 +206,21 @@ def _on_view_selected(self, vid: str) -> None: app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") w = BECMainApp(show_examples=args.examples) - w.resize(1920, 1200) + + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + w.resize(width, height) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/developer_view/__init__.py b/bec_widgets/applications/views/developer_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py new file mode 100644 index 000000000..3b28a3920 --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -0,0 +1,63 @@ +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget +from bec_widgets.applications.views.view import ViewBase + + +class DeveloperView(ViewBase): + """ + A view for users to write scripts and macros and execute them within the application. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.developer_widget = DeveloperWidget(parent=self) + self.set_content(self.developer_widget) + + # Apply stretch after the layout is done + self.set_default_view([2, 5, 3], [7, 3]) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + developer_view = DeveloperView() + _app.add_view( + icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True + ) + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py new file mode 100644 index 000000000..9e16bceb7 --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -0,0 +1,347 @@ +import re + +import markdown +from bec_lib.endpoints import MessageEndpoints +from bec_lib.script_executor import upload_script +from bec_qthemes import material_icon +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget +from shiboken6 import isValid + +import bec_widgets.widgets.containers.ads as QtAds +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.ads import CDockManager, CDockWidget +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def markdown_to_html(md_text: str) -> str: + """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" + + # Preprocess: convert consecutive >>> lines to Python code blocks + def replace_python_examples(match): + indent = match.group(1) + examples = match.group(2) + # Remove >>> prefix and clean up the code + lines = [] + for line in examples.strip().split("\n"): + line = line.strip() + if line.startswith(">>> "): + lines.append(line[4:]) # Remove '>>> ' + elif line.startswith(">>>"): + lines.append(line[3:]) # Remove '>>>' + code = "\n".join(lines) + + return f"{indent}```python\n{indent}{code}\n{indent}```" + + # Match one or more consecutive >>> lines (with same indentation) + pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" + md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) + + extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] + html = markdown.markdown( + md_text, + extensions=extensions, + extension_configs={ + "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} + }, + output_format="html", + ) + + # Remove hardcoded background colors that conflict with themes + html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) + html = re.sub(r"background: #[^;]*;", "", html) + + # Add CSS to force code blocks to wrap + css = """ + + """ + + return css + html + + +class DeveloperWidget(BECWidget, QWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + self._root_layout.addWidget(self.toolbar) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) + self.console = WebConsole(self) + self.terminal = WebConsole(self, startup_cmd="") + self.monaco = MonacoDock(self) + self.monaco.save_enabled.connect(self._on_save_enabled_update) + self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.signature_help = QTextEdit(self) + self.signature_help.setAcceptRichText(True) + self.signature_help.setReadOnly(True) + self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + opt = self.signature_help.document().defaultTextOption() + opt.setWrapMode(opt.WrapMode.WrapAnywhere) + self.signature_help.document().setDefaultTextOption(opt) + self.monaco.signature_help.connect( + lambda text: self.signature_help.setHtml(markdown_to_html(text)) + ) + self._current_script_id: str | None = None + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.console_dock = QtAds.CDockWidget("Console", self) + self.console_dock.setWidget(self.console) + + self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) + self.monaco_dock.setWidget(self.monaco) + + self.terminal_dock = QtAds.CDockWidget("Terminal", self) + self.terminal_dock.setWidget(self.terminal) + + # Monaco will be central widget + self.dock_manager.setCentralWidget(self.monaco_dock) + + # Add the dock widgets to the dock manager + area_bottom = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) + + area_left = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + ) + area_left.titleBar().setVisible(False) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, False) + + self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) + self.plotting_ads_dock.setWidget(self.plotting_ads) + + self.signature_dock = QtAds.CDockWidget("Signature Help", self) + self.signature_dock.setWidget(self.signature_help) + + area_right = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) + + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) + + self.toolbar.show_bundles(["save", "execution", "settings"]) + + def init_developer_toolbar(self): + """Initialize the developer toolbar with necessary actions and widgets.""" + save_button = MaterialIconAction( + icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self + ) + save_button.action.triggered.connect(self.on_save) + self.toolbar.components.add_safe("save", save_button) + + save_as_button = MaterialIconAction( + icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self + ) + self.toolbar.components.add_safe("save_as", save_as_button) + save_as_button.action.triggered.connect(self.on_save_as) + + save_bundle = ToolbarBundle("save", self.toolbar.components) + save_bundle.add_action("save") + save_bundle.add_action("save_as") + self.toolbar.add_bundle(save_bundle) + + run_action = MaterialIconAction( + icon_name="play_arrow", + tooltip="Run current file", + label_text="Run", + filled=True, + parent=self, + ) + run_action.action.triggered.connect(self.on_execute) + self.toolbar.components.add_safe("run", run_action) + + stop_action = MaterialIconAction( + icon_name="stop", + tooltip="Stop current execution", + label_text="Stop", + filled=True, + parent=self, + ) + stop_action.action.triggered.connect(self.on_stop) + self.toolbar.components.add_safe("stop", stop_action) + + execution_bundle = ToolbarBundle("execution", self.toolbar.components) + execution_bundle.add_action("run") + execution_bundle.add_action("stop") + self.toolbar.add_bundle(execution_bundle) + + vim_action = MaterialIconAction( + icon_name="vim", + tooltip="Toggle Vim Mode", + label_text="Vim", + filled=True, + parent=self, + checkable=True, + ) + self.toolbar.components.add_safe("vim", vim_action) + vim_action.action.triggered.connect(self.on_vim_triggered) + + settings_bundle = ToolbarBundle("settings", self.toolbar.components) + settings_bundle.add_action("vim") + self.toolbar.add_bundle(settings_bundle) + + save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + save_shortcut.activated.connect(self.on_save) + save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) + save_as_shortcut.activated.connect(self.on_save_as) + + def _open_new_file(self, file_name: str, scope: str): + self.monaco.open_file(file_name, scope) + + # Set read-only mode for shared files + if "shared" in scope: + self.monaco.set_file_readonly(file_name, True) + + # Add appropriate icon based on file type + if "script" in scope: + # Use script icon for script files + icon = material_icon("script", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + elif "macro" in scope: + # Use function icon for macro files + icon = material_icon("function", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + + @SafeSlot() + def on_save(self): + self.monaco.save_file() + + @SafeSlot() + def on_save_as(self): + self.monaco.save_file(force_save_as=True) + + @SafeSlot() + def on_vim_triggered(self): + self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) + + @SafeSlot(bool) + def _on_save_enabled_update(self, enabled: bool): + self.toolbar.components.get_action("save").action.setEnabled(enabled) + self.toolbar.components.get_action("save_as").action.setEnabled(enabled) + + @SafeSlot() + def on_execute(self): + self.script_editor_tab = self.monaco.last_focused_editor + if not self.script_editor_tab: + return + self.current_script_id = upload_script( + self.client.connector, self.script_editor_tab.widget().get_text() + ) + self.console.write(f'bec._run_script("{self.current_script_id}")') + print(f"Uploaded script with ID: {self.current_script_id}") + + @SafeSlot() + def on_stop(self): + if not self.current_script_id: + return + self.console.send_ctrl_c() + + @property + def current_script_id(self): + return self._current_script_id + + @current_script_id.setter + def current_script_id(self, value: str | None): + if value is not None and not isinstance(value, str): + raise ValueError("Script ID must be a string.") + old_script_id = self._current_script_id + self._current_script_id = value + self._update_subscription(value, old_script_id) + + def _update_subscription(self, new_script_id: str | None, old_script_id: str | None): + if old_script_id is not None: + self.bec_dispatcher.disconnect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id) + ) + if new_script_id is not None: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id) + ) + + @SafeSlot(dict, dict) + def on_script_execution_info(self, content: dict, metadata: dict): + print(f"Script execution info: {content}") + current_lines = content.get("current_lines") + if not current_lines: + self.script_editor_tab.widget().clear_highlighted_lines() + return + line_number = current_lines[0] + self.script_editor_tab.widget().clear_highlighted_lines() + self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + + def cleanup(self): + for dock in self.dock_manager.dockWidgets(): + self._delete_dock(dock) + return super().cleanup() + + def _delete_dock(self, dock: CDockWidget) -> None: + w = dock.widget() + if w and isValid(w): + w.close() + w.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py index 3b98f7568..635f68b15 100644 --- a/bec_widgets/applications/views/view.py +++ b/bec_widgets/applications/views/view.py @@ -1,6 +1,8 @@ from __future__ import annotations -from qtpy.QtCore import QEventLoop +from typing import List + +from qtpy.QtCore import QEventLoop, Qt, QTimer from qtpy.QtWidgets import ( QDialog, QDialogButtonBox, @@ -9,6 +11,7 @@ QLabel, QMessageBox, QPushButton, + QSplitter, QStackedLayout, QVBoxLayout, QWidget, @@ -20,6 +23,42 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + class ViewBase(QWidget): """Wrapper for a content widget used inside the main app's stacked view. @@ -76,6 +115,68 @@ def on_exit(self) -> bool: """ return True + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Orientation.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Orientation.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + #################################################################################################### # Example views for demonstration/testing purposes diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index ad0b9ae1c..3c9d9863f 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -3,7 +3,7 @@ from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, Qt, Signal from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.error_popups import SafeProperty @@ -24,7 +24,14 @@ class CollapsibleSection(QWidget): section_reorder_requested = Signal(str, str) # (source_title, target_title) - def __init__(self, parent=None, title="", indentation=10, show_add_button=False): + def __init__( + self, + parent=None, + title="", + indentation=10, + show_add_button=False, + tooltip: str | None = None, + ): super().__init__(parent=parent) self.title = title self.content_widget = None @@ -50,6 +57,8 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) self.header_button.mouseMoveEvent = self._header_mouse_move_event self.header_button.dragEnterEvent = self._header_drag_enter_event self.header_button.dropEvent = self._header_drop_event + if tooltip: + self.header_button.setToolTip(tooltip) self.drag_start_position = None @@ -57,13 +66,16 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) header_layout.addWidget(self.header_button) header_layout.addStretch() - self.header_add_button = QPushButton() + # Add button in header (icon-only) + self.header_add_button = QToolButton() self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.header_add_button.setFixedSize(20, 20) + self.header_add_button.setFixedSize(28, 28) self.header_add_button.setToolTip("Add item") self.header_add_button.setVisible(show_add_button) + self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.header_add_button.setAutoRaise(True) - self.header_add_button.setIcon(material_icon("add", size=(20, 20))) + self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False)) header_layout.addWidget(self.header_add_button) self.main_layout.addLayout(header_layout) @@ -106,7 +118,6 @@ def _update_appearance(self): padding: 0px; border: none; background: transparent; - color: {text_color}; icon-size: 20px 20px; }} """ diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py index b780cbdee..25bff357b 100644 --- a/bec_widgets/widgets/containers/explorer/explorer.py +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget): RPC = False PLUGIN = False - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) # Main layout self.main_layout = QVBoxLayout(self) diff --git a/bec_widgets/widgets/containers/explorer/explorer_delegate.py b/bec_widgets/widgets/containers/explorer/explorer_delegate.py new file mode 100644 index 000000000..7a8b41cbb --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/explorer_delegate.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView + +from bec_widgets.utils.colors import get_theme_palette + + +class ExplorerDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for the explorer""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QSortFilterProxyModel + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + tree_view = self.parent() + if not isinstance(tree_view, QTreeView): + return + + proxy_model = tree_view.model() + if not isinstance(proxy_model, self.target_model): + return + + actions = self.get_actions_for_current_item(proxy_model, index) + if actions: + self._draw_action_buttons(painter, option, actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + """Get actions for the current item based on its type""" + return None + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + actions = self.get_actions_for_current_item(model, index) + if not actions: + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py new file mode 100644 index 000000000..2546eb351 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -0,0 +1,382 @@ +import ast +import os +from pathlib import Path +from typing import Any + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QModelIndex, QRect, Qt, Signal +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate + +logger = bec_logger.logger + + +class MacroItemDelegate(ExplorerDelegate): + """Custom delegate to show action buttons on hover for macro functions""" + + def __init__(self, parent=None): + super().__init__(parent) + self.macro_actions: list[Any] = [] + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QStandardItemModel + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro functions""" + self.macro_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + self.macro_actions.clear() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + # Only show actions for macro functions (not directories) + item = index.model().itemFromIndex(index) + if not item or not item.data(Qt.ItemDataRole.UserRole): + return + + macro_info = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(macro_info, dict) or "function_name" not in macro_info: + return + + self.current_macro_info = macro_info + return self.macro_actions + + +class MacroTreeWidget(QWidget): + """A tree widget that displays macro functions from Python files""" + + macro_selected = Signal(str, str) # Function name, file path + macro_open_requested = Signal(str, str) # Function name, file path + + def __init__(self, parent=None): + super().__init__(parent) + + # Create layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Create tree view + self.tree = QTreeView() + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(True) + + # Disable editing to prevent renaming on double-click + self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers) + + # Enable mouse tracking for hover effects + self.tree.setMouseTracking(True) + + # Create model for macro functions + self.model = QStandardItemModel() + self.tree.setModel(self.model) + + # Create and set custom delegate + self.delegate = MacroItemDelegate(self.tree) + self.tree.setItemDelegate(self.delegate) + + # Add default open button for macros + action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self) + action.action.triggered.connect(self._on_macro_open_requested) + self.delegate.add_macro_action(action.action) + + # Apply BEC styling + self._apply_styling() + + # Macro specific properties + self.directory = None + + # Connect signals + self.tree.clicked.connect(self._on_item_clicked) + self.tree.doubleClicked.connect(self._on_item_double_clicked) + + # Install event filter for hover tracking + self.tree.viewport().installEventFilter(self) + + # Add to layout + layout.addWidget(self.tree) + + def _apply_styling(self): + """Apply styling to the tree widget""" + # Get theme colors for subtle tree lines + palette = get_theme_palette() + subtle_line_color = palette.mid().color() + subtle_line_color.setAlpha(80) + + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + + # pylint: disable=f-string-without-interpolation + tree_style = f""" + QTreeView {{ + border: none; + outline: 0; + show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} + }} + QTreeView::branch {{ + border-image: none; + background: transparent; + }} + + QTreeView::item {{ + border: none; + padding: 0px; + margin: 0px; + }} + QTreeView::item:hover {{ + background: palette(midlight); + border: none; + padding: 0px; + margin: 0px; + text-decoration: none; + }} + QTreeView::item:selected {{ + background: palette(highlight); + color: palette(highlighted-text); + }} + QTreeView::item:selected:hover {{ + background: palette(highlight); + }} + """ + + self.tree.setStyleSheet(tree_style) + + def eventFilter(self, obj, event): + """Handle mouse move events for hover tracking""" + # Early return if not the tree viewport + if obj != self.tree.viewport(): + return super().eventFilter(obj, event) + + if event.type() == event.Type.MouseMove: + index = self.tree.indexAt(event.pos()) + if index.isValid(): + self.delegate.set_hovered_index(index) + else: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + if event.type() == event.Type.Leave: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + return super().eventFilter(obj, event) + + def set_directory(self, directory): + """Set the macros directory and scan for macro functions""" + self.directory = directory + + # Early return if directory doesn't exist + if not directory or not os.path.exists(directory): + return + + self._scan_macro_functions() + + def _create_file_item(self, py_file: Path) -> QStandardItem | None: + """Create a file item with its functions + + Args: + py_file: Path to the Python file + + Returns: + QStandardItem representing the file, or None if no functions found + """ + # Skip files starting with underscore + if py_file.name.startswith("_"): + return None + + try: + functions = self._extract_functions_from_file(py_file) + if not functions: + return None + + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + return file_item + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + return None + + def _scan_macro_functions(self): + """Scan the directory for Python files and extract macro functions""" + self.model.clear() + self.model.setHorizontalHeaderLabels(["Macros"]) + + if not self.directory or not os.path.exists(self.directory): + return + + # Get all Python files in the directory + python_files = list(Path(self.directory).glob("*.py")) + + for py_file in python_files: + file_item = self._create_file_item(py_file) + if file_item: + self.model.appendRow(file_item) + + self.tree.expandAll() + + def _extract_functions_from_file(self, file_path: Path) -> dict: + """Extract function definitions from a Python file""" + functions = {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Parse the AST + tree = ast.parse(content) + + # Only get top-level function definitions + for node in tree.body: + if isinstance(node, ast.FunctionDef): + functions[node.name] = { + "line_number": node.lineno, + "docstring": ast.get_docstring(node) or "", + } + + except Exception as e: + logger.warning(f"Failed to parse {file_path}: {e}") + + return functions + + def _on_item_clicked(self, index: QModelIndex): + """Handle item clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info(f"Macro function selected: {function_name} in {file_path}") + self.macro_selected.emit(function_name, file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Handle item double-clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info( + f"Macro open requested via double-click: {function_name} in {file_path}" + ) + self.macro_open_requested.emit(function_name, file_path) + + def _on_macro_open_requested(self): + """Handle macro open action triggered""" + logger.info("Macro open requested") + # Early return if no hovered item + if not self.delegate.hovered_index.isValid(): + return + + macro_info = self.delegate.current_macro_info + if not macro_info or macro_info.get("type") != "function": + return + + function_name = macro_info.get("function_name") + file_path = macro_info.get("file_path") + if function_name and file_path: + self.macro_open_requested.emit(function_name, file_path) + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro items""" + self.delegate.add_macro_action(action) + + def clear_actions(self) -> None: + """Remove all actions from items""" + self.delegate.clear_actions() + + def refresh(self): + """Refresh the tree view""" + if self.directory is None: + return + self._scan_macro_functions() + + def refresh_file_item(self, file_path: str): + """Refresh a single file item by re-scanning its functions + + Args: + file_path: Path to the Python file to refresh + """ + if not file_path or not os.path.exists(file_path): + logger.warning(f"Cannot refresh file item: {file_path} does not exist") + return + + py_file = Path(file_path) + + # Find existing file item in the model + existing_item = None + existing_row = -1 + for row in range(self.model.rowCount()): + item = self.model.item(row) + if not item or not item.data(Qt.ItemDataRole.UserRole): + continue + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file): + existing_item = item + existing_row = row + break + + # Store expansion state if item exists + was_expanded = existing_item and self.tree.isExpanded(existing_item.index()) + + # Remove existing item if found + if existing_item and existing_row >= 0: + self.model.removeRow(existing_row) + + # Create new item using the helper method + new_item = self._create_file_item(py_file) + if new_item: + # Insert at the same position or append if it was a new file + insert_row = existing_row if existing_row >= 0 else self.model.rowCount() + self.model.insertRow(insert_row, new_item) + + # Restore expansion state + if was_expanded: + self.tree.expand(new_item.index()) + else: + self.tree.expand(new_item.index()) + + def expand_all(self): + """Expand all items in the tree""" + self.tree.expandAll() + + def collapse_all(self): + """Collapse all items in the tree""" + self.tree.collapseAll() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 86cec3493..68ff10353 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -2,32 +2,29 @@ from pathlib import Path from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QAction, QPainter -from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal +from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class FileItemDelegate(QStyledItemDelegate): +class FileItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover""" - def __init__(self, parent=None): - super().__init__(parent) - self.hovered_index = QModelIndex() - self.file_actions: list[QAction] = [] - self.dir_actions: list[QAction] = [] - self.button_rects: list[QRect] = [] - self.current_file_path = "" + def __init__(self, tree_widget): + super().__init__(tree_widget) + self.file_actions = [] + self.dir_actions = [] - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for files""" self.file_actions.append(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directories""" self.dir_actions.append(action) @@ -36,126 +33,18 @@ def clear_actions(self) -> None: self.file_actions.clear() self.dir_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - - tree_view = self.parent() - if not isinstance(tree_view, QTreeView): - return - - proxy_model = tree_view.model() - if not isinstance(proxy_model, QSortFilterProxyModel): - return - - source_index = proxy_model.mapToSource(index) - source_model = proxy_model.sourceModel() - if not isinstance(source_model, QFileSystemModel): - return - - is_dir = source_model.isDir(source_index) - file_path = source_model.filePath(source_index) - self.current_file_path = file_path - - # Choose appropriate actions based on item type - actions = self.dir_actions if is_dir else self.file_actions - if actions: - self._draw_action_buttons(painter, option, actions) - - def _draw_action_buttons(self, painter, option, actions: list[QAction]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Early return if not a proxy model + def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None: + """Get actions for the current item based on its type""" if not isinstance(model, QSortFilterProxyModel): - return super().editorEvent(event, model, option, index) + return None source_index = model.mapToSource(index) source_model = model.sourceModel() - - # Early return if not a file system model if not isinstance(source_model, QFileSystemModel): - return super().editorEvent(event, model, option, index) + return None is_dir = source_model.isDir(source_index) - actions = self.dir_actions if is_dir else self.file_actions - - # Check which button was clicked - visible_actions = [action for action in actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.dir_actions if is_dir else self.file_actions class ScriptTreeWidget(QWidget): @@ -229,12 +118,18 @@ def _apply_styling(self): subtle_line_color = palette.mid().color() subtle_line_color.setAlpha(80) + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + # pylint: disable=f-string-without-interpolation tree_style = f""" QTreeView {{ border: none; outline: 0; show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} }} QTreeView::branch {{ border-image: none; @@ -286,14 +181,14 @@ def eventFilter(self, obj, event): return super().eventFilter(obj, event) - def set_directory(self, directory): + def set_directory(self, directory: str) -> None: """Set the scripts directory""" - self.directory = directory - # Early return if directory doesn't exist - if not directory or not os.path.exists(directory): + if not directory or not isinstance(directory, str) or not os.path.exists(directory): return + self.directory = directory + root_index = self.model.setRootPath(directory) # Map the source model index to proxy model index proxy_root_index = self.proxy_model.mapFromSource(root_index) @@ -357,11 +252,11 @@ def _on_file_open_requested(self): self.file_open_requested.emit(file_path) - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for file items""" self.delegate.add_file_action(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directory items""" self.delegate.add_dir_action(action) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py new file mode 100644 index 000000000..25e8392a7 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import os +import pathlib +from typing import Any, cast + +from bec_lib.logger import bec_logger +from bec_lib.macro_update_handler import has_executable_code +from qtpy.QtCore import QEvent, QTimer, Signal +from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget + +import bec_widgets.widgets.containers.ads as QtAds +from bec_widgets import BECWidget +from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class MonacoDock(BECWidget, QWidget): + """ + MonacoDock is a dock widget that contains Monaco editor instances. + It is used to manage multiple Monaco editors in a dockable interface. + """ + + focused_editor = Signal(object) # Emitted when the focused editor changes + save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled + signature_help = Signal(str) # Emitted when signature help is requested + macro_file_updated = Signal(str) # Emitted when a macro file is saved + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = QtAds.CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) + self._root_layout.addWidget(self.dock_manager) + self.dock_manager.installEventFilter(self) + self._last_focused_editor: CDockWidget | None = None + self.focused_editor.connect(self._on_last_focused_editor_changed) + self.add_editor() + self._open_files = {} + + def _create_editor(self): + init_lsp = len(self.dock_manager.dockWidgets()) == 0 + widget = MonacoWidget(self, init_lsp=init_lsp) + widget.save_enabled.connect(self.save_enabled.emit) + widget.editor.signature_help_triggered.connect(self._on_signature_change) + count = len(self.dock_manager.dockWidgets()) + dock = CDockWidget(f"Untitled_{count + 1}") + dock.setWidget(widget) + + # Connect to modification status changes to update tab titles + widget.save_enabled.connect( + lambda modified: self._update_tab_title_for_modification(dock, modified) + ) + + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True) + + dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) + + return dock + + @property + def last_focused_editor(self) -> CDockWidget | None: + """ + Get the last focused editor. + """ + dock_widget = self.dock_manager.focusedDockWidget() + if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget): + self.last_focused_editor = dock_widget + + return self._last_focused_editor + + @last_focused_editor.setter + def last_focused_editor(self, editor: CDockWidget | None): + self._last_focused_editor = editor + self.focused_editor.emit(editor) + + def _on_last_focused_editor_changed(self, editor: CDockWidget | None): + if editor is None: + self.save_enabled.emit(False) + return + + widget = cast(MonacoWidget, editor.widget()) + if widget.modified: + logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}") + self.save_enabled.emit(widget.modified) + + def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool): + """Update the tab title to show modification status with a dot indicator.""" + current_title = dock.windowTitle() + + # Remove existing modification indicator (dot and space) + if current_title.startswith("• "): + base_title = current_title[2:] # Remove "• " + else: + base_title = current_title + + # Add or remove the modification indicator + if modified: + new_title = f"• {base_title}" + else: + new_title = base_title + + dock.setWindowTitle(new_title) + + def _on_signature_change(self, signature: dict): + signatures = signature.get("signatures", []) + if not signatures: + self.signature_help.emit("") + return + + active_sig = signatures[signature.get("activeSignature", 0)] + active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param + + # Get signature label and documentation + label = active_sig.get("label", "") + doc_obj = active_sig.get("documentation", {}) + documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj) + + # Format the markdown output + markdown = f"```python\n{label}\n```\n\n{documentation}" + self.signature_help.emit(markdown) + + def _on_focus_event(self, old_widget, new_widget) -> None: + # Track focus events for the dock widget + widget = new_widget.widget() + if isinstance(widget, MonacoWidget): + self.last_focused_editor = new_widget + + def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): + # Cast widget to MonacoWidget since we know that's what it is + monaco_widget = cast(MonacoWidget, widget) + + # Check if we have unsaved changes + if monaco_widget.modified: + # Prompt the user to save changes + response = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Do you want to save them?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if response == QMessageBox.StandardButton.Yes: + self.save_file(monaco_widget) + elif response == QMessageBox.StandardButton.Cancel: + return + + # Count all editor docks managed by this dock manager + total = len(self.dock_manager.dockWidgets()) + if total <= 1: + # Do not remove the last dock; just wipe its editor content + # Temporarily disable read-only mode if the editor is read-only + # so we can clear the content for reuse + monaco_widget.set_readonly(False) + monaco_widget.set_text("") + dock.setWindowTitle("Untitled") + dock.setTabToolTip("Untitled") + return + + # Otherwise, proceed to close and delete the dock + monaco_widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + if self.last_focused_editor is dock: + self.last_focused_editor = None + # After topology changes, make sure single-tab areas get a plus button + QTimer.singleShot(0, self._scan_and_fix_areas) + + def _ensure_area_plus(self, area): + if area is None: + return + # Only add once per area + if getattr(area, "_monaco_plus_btn", None) is not None: + return + # If the area has exactly one tab, inject a + button next to the tab bar + try: + tabbar = area.titleBar().tabBar() + count = tabbar.count() if hasattr(tabbar, "count") else 1 + except Exception: + count = 1 + if count >= 1: + plus_btn = QToolButton(area) + plus_btn.setText("+") + plus_btn.setToolTip("New Monaco Editor") + plus_btn.setAutoRaise(True) + tb = area.titleBar() + idx = tb.indexOf(tb.tabBar()) + tb.insertWidget(idx + 1, plus_btn) + plus_btn.clicked.connect(lambda: self.add_editor(area)) + # pylint: disable=protected-access + area._monaco_plus_btn = plus_btn + + def _scan_and_fix_areas(self): + # Find all dock areas under this manager and ensure each single-tab area has a plus button + areas = self.dock_manager.findChildren(CDockAreaWidget) + for a in areas: + self._ensure_area_plus(a) + + def eventFilter(self, obj, event): + # Track dock manager events + if obj is self.dock_manager and event.type() in ( + QEvent.Type.ChildAdded, + QEvent.Type.ChildRemoved, + QEvent.Type.LayoutRequest, + ): + QTimer.singleShot(0, self._scan_and_fix_areas) + + return super().eventFilter(obj, event) + + def add_editor( + self, area: Any | None = None, title: str | None = None, tooltip: str | None = None + ): # Any as qt ads does not return a proper type + """ + Adds a new Monaco editor dock widget to the dock manager. + """ + new_dock = self._create_editor() + if title is not None: + new_dock.setWindowTitle(title) + if tooltip is not None: + new_dock.setTabToolTip(tooltip) + if area is None: + area_obj = self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.TopDockWidgetArea, new_dock + ) + self._ensure_area_plus(area_obj) + else: + # If an area is provided, add the dock to that area + self.dock_manager.addDockWidgetTabToArea(new_dock, area) + self._ensure_area_plus(area) + + QTimer.singleShot(0, self._scan_and_fix_areas) + return new_dock + + def open_file(self, file_name: str, scope: str | None = None) -> None: + """ + Open a file in the specified area. If the file is already open, activate it. + """ + open_files = self._get_open_files() + if file_name in open_files: + dock = self._get_editor_dock(file_name) + if dock is not None: + dock.setAsCurrentTab() + return + + file = os.path.basename(file_name) + # If the current editor is empty, we reuse it + + # For now, the dock manager is only for the editor docks. We can therefore safely assume + # that all docks are editor docks. + dock_area = self.dock_manager.dockArea(0) + if not dock_area: + return + + editor_dock = dock_area.currentDockWidget() + if not editor_dock: + return + + editor_widget = editor_dock.widget() if editor_dock else None + if editor_widget: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + if editor_widget.current_file is None and editor_widget.get_text() == "": + editor_dock.setWindowTitle(file) + editor_dock.setTabToolTip(file_name) + editor_widget.open_file(file_name) + if scope is not None: + editor_widget.metadata["scope"] = scope + return + + # File is not open, create a new editor + editor_dock = self.add_editor(title=file, tooltip=file_name) + widget = cast(MonacoWidget, editor_dock.widget()) + widget.open_file(file_name) + if scope is not None: + widget.metadata["scope"] = scope + editor_dock.setAsCurrentTab() + + def save_file( + self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True + ) -> None: + """ + Save the currently focused file. + + Args: + widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used. + force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved. + format_on_save (bool): If True, format the code before saving if it's a Python file. + """ + if widget is None: + widget = self.last_focused_editor.widget() if self.last_focused_editor else None + if not widget: + return + if "macros" in widget.metadata.get("scope", ""): + if not self._validate_macros(widget.get_text()): + return + + if widget.current_file and not force_save_as: + if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": + widget.format() + + with open(widget.current_file, "w", encoding="utf-8") as f: + f.write(widget.get_text()) + + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(widget.current_file) + + # pylint: disable=protected-access + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + return + + # Save as option + save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") + + if not save_file or not save_file[0]: + return + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() + + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text + + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) + + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) + + logger.debug(f"Save file called, last focused editor: {self.last_focused_editor}") + + def _validate_macros(self, source: str) -> bool: + # pylint: disable=protected-access + # Ensure the macro does not contain executable code before saving + exec_code, line_number = has_executable_code(source) + if exec_code: + if line_number is None: + msg = "The macro contains executable code. Please remove it before saving." + else: + msg = f"The macro contains executable code on line {line_number}. Please remove it before saving." + QMessageBox.warning(self, "Save Error", msg) + return False + return True + + def _update_macros(self, widget: MonacoWidget): + # pylint: disable=protected-access + if not widget.current_file: + return + # Check which macros have changed and broadcast the change + macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file) + existing_macros = self.client.macros._update_handler.get_existing_macros( + widget.current_file + ) + + removed_macros = set(existing_macros.keys()) - set(macros.keys()) + added_macros = set(macros.keys()) - set(existing_macros.keys()) + for name, info in macros.items(): + if name in added_macros: + self.client.macros._update_handler.broadcast( + action="add", name=name, file_path=widget.current_file + ) + if ( + name in existing_macros + and info.get("source", "") != existing_macros[name]["source"] + ): + self.client.macros._update_handler.broadcast( + action="reload", name=name, file_path=widget.current_file + ) + for name in removed_macros: + self.client.macros._update_handler.broadcast(action="remove", name=name) + + def set_vim_mode(self, enabled: bool): + """ + Set Vim mode for all editor widgets. + + Args: + enabled (bool): Whether to enable or disable Vim mode. + """ + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + editor_widget.set_vim_mode_enabled(enabled) + + def _get_open_files(self) -> list[str]: + open_files = [] + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file is not None: + open_files.append(editor_widget.current_file) + return open_files + + def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None: + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file == file_name: + return widget + return None + + def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool: + """ + Set a specific file's editor to read-only mode. + + Args: + file_name (str): The file path to set read-only + read_only (bool): Whether to set read-only mode (default: True) + + Returns: + bool: True if the file was found and read-only was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + editor_widget.set_readonly(read_only) + return True + return False + + def set_file_icon(self, file_name: str, icon) -> bool: + """ + Set an icon for a specific file's tab. + + Args: + file_name (str): The file path to set icon for + icon: The QIcon to set on the tab + + Returns: + bool: True if the file was found and icon was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_dock.setIcon(icon) + return True + return False + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + _dock = MonacoDock() + _dock.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index eb05cec70..25fd2b3d8 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,11 +1,24 @@ -from typing import Literal +from __future__ import annotations +import os +import traceback +from typing import TYPE_CHECKING, Literal + +import black +import isort import qtmonaco +from bec_lib.logger import bec_logger from qtpy.QtCore import Signal -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot + +if TYPE_CHECKING: + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +logger = bec_logger.logger class MonacoWidget(BECWidget, QWidget): @@ -14,6 +27,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +35,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -37,7 +52,9 @@ class MonacoWidget(BECWidget, QWidget): "screenshot", ] - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs + ): super().__init__( parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs ) @@ -47,7 +64,30 @@ def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs) layout.addWidget(self.editor) self.setLayout(layout) self.editor.text_changed.connect(self.text_changed.emit) + self.editor.text_changed.connect(self._check_save_status) self.editor.initialized.connect(self.apply_theme) + self.editor.initialized.connect(self._setup_context_menu) + self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) + self._current_file = None + self._original_content = "" + self.metadata = {} + if init_lsp: + self.editor.update_workspace_configuration( + { + "pylsp": { + "plugins": { + "pylsp-bec": {"service_config": self.client._service_config.config} + } + } + } + ) + + @property + def current_file(self): + """ + Get the current file being edited. + """ + return self._current_file def apply_theme(self, theme: str | None = None) -> None: """ @@ -61,14 +101,19 @@ def apply_theme(self, theme: str | None = None) -> None: editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str) -> None: + def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ - self.editor.set_text(text) + self._current_file = file_name if file_name else self._current_file + if reset: + self._original_content = text + self.editor.set_text(text, uri=file_name) def get_text(self) -> str: """ @@ -76,6 +121,32 @@ def get_text(self) -> str: """ return self.editor.get_text() + def format(self) -> None: + """ + Format the current text in the Monaco editor. + """ + if not self.editor: + return + try: + content = self.get_text() + try: + formatted_content = black.format_str(content, mode=black.Mode(line_length=100)) + except Exception: # black.NothingChanged or other formatting exceptions + formatted_content = content + + config = isort.Config( + profile="black", + line_length=100, + multi_line_output=3, + include_trailing_comma=False, + known_first_party=["bec_widgets"], + ) + formatted_content = isort.code(formatted_content, config=config) + self.set_text(formatted_content, file_name=self.current_file) + except Exception: + content = traceback.format_exc() + logger.info(content) + def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: """ Insert text at the current cursor position or at a specified line and column. @@ -96,6 +167,32 @@ def delete_line(self, line: int | None = None) -> None: """ self.editor.delete_line(line) + def open_file(self, file_name: str) -> None: + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + if not os.path.exists(file_name): + raise FileNotFoundError(f"The specified file does not exist: {file_name}") + + with open(file_name, "r", encoding="utf-8") as file: + content = file.read() + self.set_text(content, file_name=file_name, reset=True) + + @property + def modified(self) -> bool: + """ + Check if the editor content has been modified. + """ + return self._original_content != self.get_text() + + @SafeSlot(str) + def _check_save_status(self, _text: str) -> None: + self.save_enabled.emit(self.modified) + def set_cursor( self, line: int, @@ -213,6 +310,46 @@ def get_lsp_header(self) -> str: """ return self.editor.get_lsp_header() + def _setup_context_menu(self): + """Setup custom context menu actions for the Monaco editor.""" + # Add the "Insert Scan" action to the context menu + self.editor.add_action("insert_scan", "Insert Scan", "python") + # Add the "Format Code" action to the context menu + self.editor.add_action("format_code", "Format Code", "python") + + def _handle_context_menu_action(self, action_id: str): + """Handle context menu action triggers.""" + if action_id == "insert_scan": + self._show_scan_control_dialog() + elif action_id == "format_code": + self._format_code() + + def _show_scan_control_dialog(self): + """Show the scan control dialog and insert the generated scan code.""" + # Import here to avoid circular imports + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + + dialog = ScanControlDialog(self, client=self.client) + self._run_dialog_and_insert_code(dialog) + + def _run_dialog_and_insert_code(self, dialog: ScanControlDialog): + """ + Run the dialog and insert the generated scan code if accepted. + It is a separate method to allow easier testing. + + Args: + dialog (ScanControlDialog): The scan control dialog instance. + """ + if dialog.exec_() == QDialog.DialogCode.Accepted: + scan_code = dialog.get_scan_code() + if scan_code: + # Insert the scan code at the current cursor position + self.insert_text(scan_code) + + def _format_code(self): + """Format the current code in the editor.""" + self.format() + if __name__ == "__main__": # pragma: no cover qapp = QApplication([]) @@ -234,7 +371,7 @@ def get_lsp_header(self) -> str: scans: Scans ####################################### -########## User Script ##################### +########## User Script ################ ####################################### # This is a comment diff --git a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py new file mode 100644 index 000000000..2cbb7121d --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -0,0 +1,145 @@ +""" +Scan Control Dialog for Monaco Editor + +This module provides a dialog wrapper around the ScanControl widget, +allowing users to configure and generate scan code that can be inserted +into the Monaco editor. +""" + +from bec_lib.device import Device +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout + +from bec_widgets.widgets.control.scan_control import ScanControl + +logger = bec_logger.logger + + +class ScanControlDialog(QDialog): + """ + Dialog window containing the ScanControl widget for generating scan code. + + This dialog allows users to configure scan parameters and generates + Python code that can be inserted into the Monaco editor. + """ + + def __init__(self, parent=None, client=None): + super().__init__(parent) + self.setWindowTitle("Insert Scan") + + # Store the client for passing to ScanControl + self.client = client + self._scan_code = "" + + self._setup_ui() + + def sizeHint(self) -> QSize: + return QSize(600, 800) + + def _setup_ui(self): + """Setup the dialog UI with ScanControl widget and buttons.""" + layout = QVBoxLayout(self) + + # Create the scan control widget + self.scan_control = ScanControl(parent=self, client=self.client) + self.scan_control.show_scan_control_buttons(False) + layout.addWidget(self.scan_control) + + # Create dialog buttons + button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self) + + # Create custom buttons with appropriate text + insert_button = QPushButton("Insert") + cancel_button = QPushButton("Cancel") + + button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) + + # Connect button signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def _generate_scan_code(self): + """Generate Python code for the configured scan.""" + try: + # Get scan parameters from the scan control widget + args, kwargs = self.scan_control.get_scan_parameters() + scan_name = self.scan_control.current_scan + + if not scan_name: + self._scan_code = "" + return + + # Process arguments and add device prefix where needed + processed_args = self._process_arguments_for_code_generation(args) + processed_kwargs = self._process_kwargs_for_code_generation(kwargs) + + # Generate the Python code string + code_parts = [] + + # Process arguments and keyword arguments + all_args = [] + + # Add positional arguments + if processed_args: + all_args.extend(processed_args) + + # Add keyword arguments (excluding metadata) + if processed_kwargs: + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"] + all_args.extend(kwargs_strs) + + # Join all arguments and create the scan call + args_str = ", ".join(all_args) + if args_str: + code_parts.append(f"scans.{scan_name}({args_str})") + else: + code_parts.append(f"scans.{scan_name}()") + + self._scan_code = "\n".join(code_parts) + + except Exception as e: + logger.error(f"Error generating scan code: {e}") + self._scan_code = f"# Error generating scan code: {e}\n" + + def _process_arguments_for_code_generation(self, args): + """Process arguments to add device prefixes and proper formatting.""" + return [self._format_value_for_code(arg) for arg in args] + + def _process_kwargs_for_code_generation(self, kwargs): + """Process keyword arguments to add device prefixes and proper formatting.""" + return {key: self._format_value_for_code(value) for key, value in kwargs.items()} + + def _format_value_for_code(self, value): + """Format a single value for code generation.""" + if isinstance(value, Device): + return f"dev.{value.name}" + return repr(value) + + def get_scan_code(self) -> str: + """ + Get the generated scan code. + + Returns: + str: The Python code for the configured scan. + """ + return self._scan_code + + def accept(self): + """Override accept to generate code before closing.""" + self._generate_scan_code() + super().accept() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = ScanControlDialog() + dialog.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 38a5b2744..2c19a1760 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -1,13 +1,19 @@ import datetime import importlib +import importlib.metadata import os +import re +from typing import Literal +from bec_qthemes import material_icon +from qtpy.QtCore import Signal from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection from bec_widgets.widgets.containers.explorer.explorer import Explorer +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget @@ -17,16 +23,19 @@ class IDEExplorer(BECWidget, QWidget): PLUGIN = True RPC = False + file_open_requested = Signal(str, str) + file_preview_requested = Signal(str, str) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._sections = set() + self._sections = [] # Use list to maintain order instead of set self.main_explorer = Explorer(parent=self) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.main_explorer) self.setLayout(layout) - self.sections = ["scripts"] + self.sections = ["scripts", "macros"] @SafeProperty(list) def sections(self): @@ -35,10 +44,16 @@ def sections(self): @sections.setter def sections(self, value): existing_sections = set(self._sections) - self._sections = set(value) - self._update_section_visibility(self._sections - existing_sections) + new_sections = set(value) + # Find sections to add, maintaining the order from the input value list + sections_to_add = [ + section for section in value if section in (new_sections - existing_sections) + ] + self._sections = list(value) # Store as ordered list + self._update_section_visibility(sections_to_add) def _update_section_visibility(self, sections): + # sections is now an ordered list, not a set for section in sections: self._add_section(section) @@ -46,15 +61,29 @@ def _add_section(self, section_name): match section_name.lower(): case "scripts": self.add_script_section() + case "macros": + self.add_macro_section() case _: pass + def _remove_section(self, section_name): + section = self.main_explorer.get_section(section_name.upper()) + if section: + self.main_explorer.remove_section(section) + self._sections.remove(section_name) + + def clear(self): + """Clear all sections from the explorer.""" + for section in reversed(self._sections): + self._remove_section(section) + def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) - section.expanded = False script_explorer = Explorer(parent=self) script_widget = ScriptTreeWidget(parent=self) + script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) + script_widget.file_selected.connect(self._emit_file_preview_scripts_local) local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) local_scripts_section.header_add_button.clicked.connect(self._add_local_script) local_scripts_section.set_widget(script_widget) @@ -67,24 +96,98 @@ def add_script_section(self): section.set_widget(script_explorer) self.main_explorer.add_section(section) - plugin_scripts_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts") - break + plugin_scripts_dir = self._get_plugin_dir("scripts") if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return - shared_script_section = CollapsibleSection(title="Shared", parent=self) + shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_script_section.setToolTip("Shared scripts (read-only)") shared_script_widget = ScriptTreeWidget(parent=self) shared_script_section.set_widget(shared_script_widget) shared_script_widget.set_directory(plugin_scripts_dir) script_explorer.add_section(shared_script_section) - # macros_section = CollapsibleSection("MACROS", indentation=0) - # macros_section.set_widget(QLabel("Macros will be implemented later")) - # self.main_explorer.add_section(macros_section) + shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) + shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) + + def add_macro_section(self): + section = CollapsibleSection( + parent=self, + title="MACROS", + indentation=0, + show_add_button=True, + tooltip="Macros are reusable functions that can be called from scripts or the console.", + ) + section.header_add_button.setIcon( + material_icon("refresh", size=(20, 20), convert_to_pixmap=False) + ) + section.header_add_button.setToolTip("Reload all macros") + section.header_add_button.clicked.connect(self._reload_macros) + + macro_explorer = Explorer(parent=self) + macro_widget = MacroTreeWidget(parent=self) + macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local) + macro_widget.macro_selected.connect(self._emit_file_preview_macros_local) + local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_macros_section.header_add_button.clicked.connect(self._add_local_macro) + local_macros_section.set_widget(macro_widget) + local_macro_dir = self.client._service_config.model.user_macros.base_path + if not os.path.exists(local_macro_dir): + os.makedirs(local_macro_dir) + macro_widget.set_directory(local_macro_dir) + macro_explorer.add_section(local_macros_section) + + section.set_widget(macro_explorer) + self.main_explorer.add_section(section) + + plugin_macros_dir = self._get_plugin_dir("macros") + + if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): + return + shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_macro_section.setToolTip("Shared macros (read-only)") + shared_macro_widget = MacroTreeWidget(parent=self) + shared_macro_section.set_widget(shared_macro_widget) + shared_macro_widget.set_directory(plugin_macros_dir) + macro_explorer.add_section(shared_macro_section) + shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) + shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + + def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None: + """Get the path to the specified directory within the BEC plugin. + + Returns: + The path to the specified directory, or None if not found. + """ + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + return os.path.join(plugin.__path__[0], dir_name) + return None + + def _emit_file_open_scripts_local(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/local") + + def _emit_file_preview_scripts_local(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/local") + + def _emit_file_open_scripts_shared(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/shared") + + def _emit_file_preview_scripts_shared(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/shared") + + def _emit_file_open_macros_local(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/local") + + def _emit_file_preview_macros_local(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/local") + + def _emit_file_open_macros_shared(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/shared") + + def _emit_file_preview_macros_shared(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/shared") def _add_local_script(self): """Show a dialog to enter the name of a new script and create it.""" @@ -136,6 +239,134 @@ def _add_local_script(self): # Show error if file creation failed QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}") + def _add_local_macro(self): + """Show a dialog to enter the name of a new macro function and create it.""" + + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + + local_macro_dir = macro_dir_section.content_widget.directory + + # Prompt user for function name + function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:") + + if not ok or not function_name: + return # User cancelled or didn't enter a name + + # Sanitize function name + function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name) + if not function_name or function_name[0].isdigit(): + QMessageBox.warning( + self, "Invalid Name", "Function name must be a valid Python identifier." + ) + return + + # Create filename based on function name + filename = f"{function_name}.py" + file_path = os.path.join(local_macro_dir, filename) + + # Check if file already exists + if os.path.exists(file_path): + response = QMessageBox.question( + self, + "File exists", + f"The file '{filename}' already exists. Do you want to overwrite it?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if response != QMessageBox.StandardButton.Yes: + return # User chose not to overwrite + + try: + # Create the file with a macro function template + with open(file_path, "w", encoding="utf-8") as f: + f.write( + f'''""" +{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + + +def {function_name}(): + """ + Description of what this macro does. + + Add your macro implementation here. + """ + print("Executing macro: {function_name}") + # TODO: Add your macro code here + pass +''' + ) + + # Refresh the macro tree to show the new function + macro_dir_section.content_widget.refresh() + + except Exception as e: + # Show error if file creation failed + QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}") + + def _reload_macros(self): + """Reload all macros using the BEC client.""" + try: + if hasattr(self.client, "macros"): + self.client.macros.load_all_user_macros() + + # Refresh the macro tree widgets to show updated functions + target_section = self.main_explorer.get_section("MACROS") + if target_section and hasattr(target_section, "content_widget"): + local_section = target_section.content_widget.get_section("Local") + if local_section and hasattr(local_section, "content_widget"): + local_section.content_widget.refresh() + + shared_section = target_section.content_widget.get_section("Shared") + if shared_section and hasattr(shared_section, "content_widget"): + shared_section.content_widget.refresh() + + QMessageBox.information( + self, "Reload Macros", "Macros have been reloaded successfully." + ) + else: + QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + + def refresh_macro_file(self, file_path: str): + """Refresh a single macro file in the tree widget. + + Args: + file_path: Path to the macro file that was updated + """ + target_section = self.main_explorer.get_section("MACROS") + if not target_section or not hasattr(target_section, "content_widget"): + return + + # Determine if this is a local or shared macro based on the file path + local_section = target_section.content_widget.get_section("Local") + shared_section = target_section.content_widget.get_section("Shared") + + # Check if file belongs to local macros directory + if ( + local_section + and hasattr(local_section, "content_widget") + and hasattr(local_section.content_widget, "directory") + ): + local_macro_dir = local_section.content_widget.directory + if local_macro_dir and file_path.startswith(local_macro_dir): + local_section.content_widget.refresh_file_item(file_path) + return + + # Check if file belongs to shared macros directory + if ( + shared_section + and hasattr(shared_section, "content_widget") + and hasattr(shared_section.content_widget, "directory") + ): + shared_macro_dir = shared_section.content_widget.directory + if shared_macro_dir and file_path.startswith(shared_macro_dir): + shared_section.content_widget.refresh_file_item(file_path) + return + if __name__ == "__main__": from qtpy.QtWidgets import QApplication diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py index ce99a35e2..2c1c60bbd 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py @@ -1,7 +1,7 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause - from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -20,6 +20,8 @@ def __init__(self): self._form_editor = None def createWidget(self, parent): + if parent is None: + return QWidget() t = IDEExplorer(parent) return t diff --git a/tests/unit_tests/test_collapsible_tree_section.py b/tests/unit_tests/test_collapsible_tree_section.py new file mode 100644 index 000000000..028f5fe03 --- /dev/null +++ b/tests/unit_tests/test_collapsible_tree_section.py @@ -0,0 +1,119 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +from unittest import mock + +import pytest +from qtpy.QtCore import QMimeData, QPoint, Qt +from qtpy.QtWidgets import QLabel + +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection + + +@pytest.fixture +def collapsible_section(qtbot): + """Create a basic CollapsibleSection widget for testing""" + widget = CollapsibleSection(title="Test Section") + qtbot.addWidget(widget) + yield widget + + +@pytest.fixture +def dummy_content_widget(qtbot): + """Create a simple widget to be used as content""" + widget = QLabel("Test Content") + qtbot.addWidget(widget) + return widget + + +def test_basic_initialization(collapsible_section): + """Test basic initialization""" + assert collapsible_section.title == "Test Section" + assert collapsible_section.expanded is True + assert collapsible_section.content_widget is None + + +def test_toggle_expanded(collapsible_section): + """Test toggling expansion state""" + assert collapsible_section.expanded is True + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is False + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is True + + +def test_set_widget(collapsible_section, dummy_content_widget): + """Test setting content widget""" + collapsible_section.set_widget(dummy_content_widget) + assert collapsible_section.content_widget == dummy_content_widget + assert dummy_content_widget.parent() == collapsible_section + + +def test_connect_add_button(qtbot): + """Test connecting add button""" + widget = CollapsibleSection(title="Test", show_add_button=True) + qtbot.addWidget(widget) + + mock_slot = mock.MagicMock() + widget.connect_add_button(mock_slot) + + qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton) + mock_slot.assert_called_once() + + +def test_section_reorder_signal(collapsible_section): + """Test section reorder signal emission""" + signals_received = [] + collapsible_section.section_reorder_requested.connect( + lambda source, target: signals_received.append((source, target)) + ) + + # Create mock drop event + mime_data = QMimeData() + mime_data.setText("section:Source Section") + + mock_event = mock.MagicMock() + mock_event.mimeData.return_value = mime_data + + collapsible_section._header_drop_event(mock_event) + + assert len(signals_received) == 1 + assert signals_received[0] == ("Source Section", "Test Section") + + +def test_nested_collapsible_sections(qtbot): + """Test that collapsible sections can be nested""" + # Create parent section + parent_section = CollapsibleSection(title="Parent Section") + qtbot.addWidget(parent_section) + + # Create child section + child_section = CollapsibleSection(title="Child Section") + qtbot.addWidget(child_section) + + # Add some content to the child section + child_content = QLabel("Child Content") + qtbot.addWidget(child_content) + child_section.set_widget(child_content) + + # Nest the child section inside the parent + parent_section.set_widget(child_section) + + # Verify nesting structure + assert parent_section.content_widget == child_section + assert child_section.parent() == parent_section + assert child_section.content_widget == child_content + assert child_content.parent() == child_section + + # Test that both sections can expand/collapse independently + assert parent_section.expanded is True + assert child_section.expanded is True + + # Collapse child section + child_section.toggle_expanded() + assert child_section.expanded is False + assert parent_section.expanded is True # Parent should remain expanded + + # Collapse parent section + parent_section.toggle_expanded() + assert parent_section.expanded is False + assert child_section.expanded is False # Child state unchanged diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py new file mode 100644 index 000000000..56971d3b7 --- /dev/null +++ b/tests/unit_tests/test_developer_view.py @@ -0,0 +1,378 @@ +""" +Unit tests for the Developer View widget. + +This module tests the DeveloperView widget functionality including: +- Widget initialization and setup +- Monaco editor integration +- IDE Explorer integration +- File operations (open, save, format) +- Context menu actions +- Toolbar functionality +""" + +import os +import tempfile +from unittest import mock + +import pytest +from qtpy.QtWidgets import QDialog + +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +from .client_mocks import mocked_client + + +@pytest.fixture +def developer_view(qtbot, mocked_client): + """Create a DeveloperWidget for testing.""" + widget = DeveloperWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_python_file(): + """Create a temporary Python file for testing.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write( + """# Test Python file +import os +import sys + +def test_function(): + return "Hello, World!" + +if __name__ == "__main__": + print(test_function()) +""" + ) + temp_file_path = f.name + + yield temp_file_path + + # Cleanup + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +@pytest.fixture +def mock_scan_control_dialog(): + """Mock the ScanControlDialog for testing.""" + with mock.patch( + "bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog" + ) as mock_dialog: + # Configure the mock dialog + mock_dialog_instance = mock.MagicMock() + mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.get_scan_code.return_value = ( + "scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)" + ) + mock_dialog.return_value = mock_dialog_instance + yield mock_dialog_instance + + +class TestDeveloperViewInitialization: + """Test developer view initialization and basic functionality.""" + + def test_developer_view_initialization(self, developer_view): + """Test that the developer view initializes correctly.""" + # Check that main components are created + assert hasattr(developer_view, "monaco") + assert hasattr(developer_view, "explorer") + assert hasattr(developer_view, "console") + assert hasattr(developer_view, "terminal") + assert hasattr(developer_view, "toolbar") + assert hasattr(developer_view, "dock_manager") + assert hasattr(developer_view, "plotting_ads") + assert hasattr(developer_view, "signature_help") + + def test_monaco_editor_integration(self, developer_view): + """Test that Monaco editor is properly integrated.""" + assert isinstance(developer_view.monaco, MonacoDock) + assert developer_view.monaco.parent() is not None + + def test_ide_explorer_integration(self, developer_view): + """Test that IDE Explorer is properly integrated.""" + assert isinstance(developer_view.explorer, IDEExplorer) + assert developer_view.explorer.parent() is not None + + def test_toolbar_components(self, developer_view): + """Test that toolbar components are properly set up.""" + assert developer_view.toolbar is not None + + # Check for expected toolbar actions + toolbar_components = developer_view.toolbar.components + expected_actions = ["save", "save_as", "run", "stop", "vim"] + + for action_name in expected_actions: + assert toolbar_components.exists(action_name) + + def test_dock_manager_setup(self, developer_view): + """Test that dock manager is properly configured.""" + assert developer_view.dock_manager is not None + + # Check that docks are added + dock_widgets = developer_view.dock_manager.dockWidgets() + assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal + + +class TestFileOperations: + """Test file operation functionality.""" + + def test_open_new_file(self, developer_view, temp_python_file, qtbot): + """Test opening a new file in the Monaco editor.""" + # Simulate opening a file through the IDE explorer signal + developer_view._open_new_file(temp_python_file, "scripts/local") + + # Wait for the file to be loaded + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file was opened + assert temp_python_file in developer_view.monaco._get_open_files() + + # Check that content was loaded (simplified check) + # Get the editor dock for the file and check its content + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + editor_widget = dock.widget() + assert "test_function" in editor_widget.get_text() + + def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot): + """Test that shared files are opened in read-only mode.""" + # Open file with shared scope + developer_view._open_new_file(temp_python_file, "scripts/shared") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file is set to read-only + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + monaco_widget = dock.widget() + # Check that the widget is in read-only mode + # This depends on MonacoWidget having a readonly property or method + assert monaco_widget is not None + + def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot): + """Test that file icons are assigned based on scope.""" + # Test script file icon + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that an icon was set (simplified check) + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + assert not dock.icon().isNull() + + def test_save_functionality(self, developer_view, qtbot): + """Test the save functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save() + mock_save.assert_called_once() + + def test_save_as_functionality(self, developer_view, qtbot): + """Test the save as functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save as test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save as action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save_as() + mock_save.assert_called_once_with(force_save_as=True) + + +class TestMonacoEditorIntegration: + """Test Monaco editor specific functionality.""" + + def test_vim_mode_toggle(self, developer_view, qtbot): + """Test vim mode toggle functionality.""" + # Test enabling vim mode + with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim: + developer_view.on_vim_triggered() + # The actual call depends on the checkbox state + mock_vim.assert_called_once() + + def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot): + """Test the Insert Scan context menu action.""" + # This functionality is handled by individual MonacoWidget instances + # Test that the dock has editor widgets + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + assert len(dock_widgets) >= 1 + + # Test on the first available editor + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + assert isinstance(monaco_widget, MonacoWidget) + + def test_context_menu_format_code(self, developer_view, qtbot): + """Test the Format Code context menu action.""" + # Get an editor widget from the dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Set some unformatted Python code + unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x" + monaco_widget.set_text(unformatted_code) + + qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000) + + # Test format action on the individual widget + with mock.patch.object(monaco_widget, "format") as mock_format: + monaco_widget.format() + mock_format.assert_called_once() + + def test_save_enabled_signal_handling(self, developer_view, qtbot): + """Test that save enabled signals are handled correctly.""" + # Mock the toolbar update method + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate save enabled signal + developer_view.monaco.save_enabled.emit(True) + mock_update.assert_called_with(True) + + developer_view.monaco.save_enabled.emit(False) + mock_update.assert_called_with(False) + + +class TestIDEExplorerIntegration: + """Test IDE Explorer integration.""" + + def test_file_open_signal_connection(self, developer_view): + """Test that file open signals are properly connected.""" + # Test that the signal connection works by mocking the connected method + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the signal to test the connection + developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local") + mock_open.assert_called_once_with("test_file.py", "scripts/local") + + def test_file_preview_signal_connection(self, developer_view): + """Test that file preview signals are properly connected.""" + # Test that the signal exists and can be emitted (basic connection test) + try: + developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local") + # If no exception is raised, the signal exists and is connectable + assert True + except AttributeError: + assert False, "file_preview_requested signal not found" + + def test_sections_configuration(self, developer_view): + """Test that IDE Explorer sections are properly configured.""" + assert "scripts" in developer_view.explorer.sections + assert "macros" in developer_view.explorer.sections + + +class TestToolbarIntegration: + """Test toolbar functionality and integration.""" + + def test_toolbar_save_button_state(self, developer_view): + """Test toolbar save button state management.""" + # Test that save buttons exist and can be controlled + save_action = developer_view.toolbar.components.get_action("save") + save_as_action = developer_view.toolbar.components.get_action("save_as") + + # Test that the actions exist and are accessible + assert save_action.action is not None + assert save_as_action.action is not None + + # Test that they can be enabled/disabled via the update method + developer_view._on_save_enabled_update(False) + assert not save_action.action.isEnabled() + assert not save_as_action.action.isEnabled() + + developer_view._on_save_enabled_update(True) + assert save_action.action.isEnabled() + assert save_as_action.action.isEnabled() + + def test_vim_mode_button_toggle(self, developer_view, qtbot): + """Test vim mode button toggle functionality.""" + vim_action = developer_view.toolbar.components.get_action("vim") + + if vim_action: + # Test toggling vim mode + initial_state = vim_action.action.isChecked() + + # Simulate button click + vim_action.action.trigger() + + # Check that state changed + assert vim_action.action.isChecked() != initial_state + + +class TestErrorHandling: + """Test error handling in various scenarios.""" + + def test_invalid_scope_handling(self, developer_view, temp_python_file): + """Test handling of invalid scope parameters.""" + # Test with invalid scope + try: + developer_view._open_new_file(temp_python_file, "invalid/scope") + except Exception as e: + assert False, f"Invalid scope should be handled gracefully: {e}" + + def test_monaco_editor_error_handling(self, developer_view): + """Test error handling in Monaco editor operations.""" + # Test with editor widgets from dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Test setting invalid text + try: + monaco_widget.set_text(None) # This might cause an error + except Exception: + # Errors should be handled gracefully + pass + + +class TestSignalIntegration: + """Test signal connections and data flow.""" + + def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot): + """Test the complete file open signal flow.""" + # Mock the _open_new_file method to verify it gets called + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the file open signal from explorer + developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local") + + # Verify the signal was handled + mock_open.assert_called_once_with(temp_python_file, "scripts/local") + + def test_save_enabled_signal_flow(self, developer_view, qtbot): + """Test the save enabled signal flow.""" + # Mock the update method (the actual method is _on_save_enabled_update) + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate monaco dock emitting save enabled signal + developer_view.monaco.save_enabled.emit(True) + + # Verify the signal was handled + mock_update.assert_called_once_with(True) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index ba1b9eecc..cfdf3d5f0 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -1,7 +1,9 @@ import os +from pathlib import Path from unittest import mock import pytest +from qtpy.QtWidgets import QMessageBox from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -34,3 +36,423 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): ): ide_explorer._add_local_script() assert os.path.exists(os.path.join(tmpdir, "test_file.py")) + + +def test_shared_scripts_section_with_files(ide_explorer, tmpdir): + """Test that shared scripts section is created when plugin directory has files""" + # Create dummy shared script files + shared_scripts_dir = tmpdir.mkdir("shared_scripts") + shared_scripts_dir.join("shared_script1.py").write("# Shared script 1") + shared_scripts_dir.join("shared_script2.py").write("# Shared script 2") + + ide_explorer.clear() + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_scripts_dir) + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should have both Local and Shared sections + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_macros_section_with_files(ide_explorer, tmpdir): + """Test that shared macros section is created when plugin directory has files""" + # Create dummy shared macro files + shared_macros_dir = tmpdir.mkdir("shared_macros") + shared_macros_dir.join("shared_macro1.py").write( + """ +def shared_function1(): + return "shared1" + +def shared_function2(): + return "shared2" +""" + ) + shared_macros_dir.join("utilities.py").write( + """ +def utility_function(): + return "utility" +""" + ) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_macros_dir) + + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + macros_section = ide_explorer.main_explorer.get_section("MACROS") + assert macros_section is not None + + # Should have both Local and Shared sections + local_section = macros_section.content_widget.get_section("Local") + shared_section = macros_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer): + """Test that shared sections are not added when plugin directories don't exist""" + ide_explorer.clear() + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = None + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir): + """Test that shared sections are not added when plugin directory doesn't exist on disk""" + ide_explorer.clear() + # Return a path that doesn't exist + nonexistent_path = str(tmpdir.join("nonexistent")) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = nonexistent_path + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section since directory doesn't exist + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +@pytest.mark.parametrize( + "slot, signal, file_name,scope", + [ + ( + "_emit_file_open_scripts_local", + "file_open_requested", + "example_script.py", + "scripts/local", + ), + ( + "_emit_file_preview_scripts_local", + "file_preview_requested", + "example_macro.py", + "scripts/local", + ), + ( + "_emit_file_open_scripts_shared", + "file_open_requested", + "example_script.py", + "scripts/shared", + ), + ( + "_emit_file_preview_scripts_shared", + "file_preview_requested", + "example_macro.py", + "scripts/shared", + ), + ], +) +def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope): + """Test that the correct signals are emitted when files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(file_name) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_name, scope)] + + +@pytest.mark.parametrize( + "slot, signal, func_name, file_path,scope", + [ + ( + "_emit_file_open_macros_local", + "file_open_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_preview_macros_local", + "file_preview_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_open_macros_shared", + "file_open_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ( + "_emit_file_preview_macros_shared", + "file_preview_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ], +) +def test_ide_explorer_file_signals_macros( + ide_explorer, qtbot, slot, signal, func_name, file_path, scope +): + """Test that the correct signals are emitted when macro files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(func_name, file_path) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_path, scope)] + + +def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir): + """Test adding a local macro through the UI""" + # Create macros section first + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("test_macro_function", True), + ): + ide_explorer._add_local_macro() + + # Check that the macro file was created + expected_file = os.path.join(tmpdir, "test_macro_function.py") + assert os.path.exists(expected_file) + + # Check that the file contains the expected function + with open(expected_file, "r") as f: + content = f.read() + assert "def test_macro_function():" in content + assert "test_macro_function macro" in content + + +def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir): + """Test adding a local macro with invalid function name""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Test with invalid function name (starts with number) + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("123invalid", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning" + ) as mock_warning, + ): + ide_explorer._add_local_macro() + + # Should show warning message + mock_warning.assert_called_once() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir): + """Test adding a local macro when file already exists""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create an existing file + existing_file = Path(tmpdir) / "existing_macro.py" + existing_file.write_text("# Existing macro") + + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("existing_macro", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question", + return_value=QMessageBox.StandardButton.Yes, + ) as mock_question, + ): + ide_explorer._add_local_macro() + + # Should ask for overwrite confirmation + mock_question.assert_called_once() + + # File should be overwritten with new content + with open(existing_file, "r") as f: + content = f.read() + assert "def existing_macro():" in content + + +def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir): + """Test cancelling the add local macro dialog""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # User cancels the dialog + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("", False), # User cancelled + ): + ide_explorer._add_local_macro() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_reload_macros_success(ide_explorer, qtbot): + """Test successful macro reloading""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock the client and macros + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information" + ) as mock_info: + ide_explorer._reload_macros() + + # Should call load_all_user_macros + mock_macros.load_all_user_macros.assert_called_once() + + # Should show success message + mock_info.assert_called_once() + assert "successfully" in mock_info.call_args[0][2] + + +def test_ide_explorer_reload_macros_error(ide_explorer, qtbot): + """Test macro reloading when an error occurs""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock client with macros that raises an exception + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_macros.load_all_user_macros.side_effect = Exception("Test error") + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical" + ) as mock_critical: + ide_explorer._reload_macros() + + # Should show error message + mock_critical.assert_called_once() + assert "Failed to reload macros" in mock_critical.call_args[0][2] + + +def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir): + """Test refreshing a local macro file""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create a test macro file + macro_file = Path(tmpdir) / "test_macro.py" + macro_file.write_text("def test_function(): pass") + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(str(macro_file)) + + # Should call refresh_file_item with the file path + mock_refresh.assert_called_once_with(str(macro_file)) + + +def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir): + """Test refreshing a macro file that doesn't match any directory""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Try to refresh a file that's not in any macro directory + unrelated_file = "/some/other/path/unrelated.py" + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(unrelated_file) + + # Should not call refresh_file_item + mock_refresh.assert_not_called() + + +def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot): + """Test refreshing a macro file when no macro sections exist""" + ide_explorer.clear() + # Don't add macros section + + # Should handle gracefully without error + ide_explorer.refresh_macro_file("/some/path/test.py") + # Test passes if no exception is raised diff --git a/tests/unit_tests/test_macro_tree_widget.py b/tests/unit_tests/test_macro_tree_widget.py new file mode 100644 index 000000000..501836cb0 --- /dev/null +++ b/tests/unit_tests/test_macro_tree_widget.py @@ -0,0 +1,548 @@ +""" +Unit tests for the MacroTreeWidget. +""" + +from pathlib import Path + +import pytest +from qtpy.QtCore import QEvent, QModelIndex, Qt +from qtpy.QtGui import QMouseEvent + +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget + + +@pytest.fixture +def temp_macro_files(tmpdir): + """Create temporary macro files for testing.""" + macro_dir = Path(tmpdir) / "macros" + macro_dir.mkdir() + + # Create a simple macro file with functions + macro_file1 = macro_dir / "test_macros.py" + macro_file1.write_text( + ''' +def test_macro_function(): + """A test macro function.""" + return "test" + +def another_function(param1, param2): + """Another function with parameters.""" + return param1 + param2 + +class TestClass: + """This class should be ignored.""" + def method(self): + pass +''' + ) + + # Create another macro file + macro_file2 = macro_dir / "utils_macros.py" + macro_file2.write_text( + ''' +def utility_function(): + """A utility function.""" + pass + +def deprecated_function(): + """Old function.""" + return None +''' + ) + + # Create a file with no functions (should be ignored) + empty_file = macro_dir / "empty.py" + empty_file.write_text( + """ +# Just a comment +x = 1 +y = 2 +""" + ) + + # Create a file starting with underscore (should be ignored) + private_file = macro_dir / "_private.py" + private_file.write_text( + """ +def private_function(): + return "private" +""" + ) + + # Create a file with syntax errors + error_file = macro_dir / "error_file.py" + error_file.write_text( + """ +def broken_function( + # Missing closing parenthesis and colon + pass +""" + ) + + return macro_dir + + +@pytest.fixture +def macro_tree(qtbot, temp_macro_files): + """Create a MacroTreeWidget with test macro files.""" + widget = MacroTreeWidget() + widget.set_directory(str(temp_macro_files)) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestMacroTreeWidgetInitialization: + """Test macro tree widget initialization and basic functionality.""" + + def test_initialization(self, qtbot): + """Test that the macro tree widget initializes correctly.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Check basic properties + assert widget.tree is not None + assert widget.model is not None + assert widget.delegate is not None + assert widget.directory is None + + # Check that tree is configured properly + assert widget.tree.isHeaderHidden() + assert widget.tree.rootIsDecorated() + assert not widget.tree.editTriggers() + + def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files): + """Test setting a valid directory path.""" + assert macro_tree.directory == str(temp_macro_files) + + # Check that files were loaded + assert macro_tree.model.rowCount() > 0 + + # Should have 2 files (test_macros.py and utils_macros.py) + # empty.py and _private.py should be filtered out + expected_files = ["test_macros", "utils_macros"] + actual_files = [] + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + actual_files.append(item.text()) + + # Sort for consistent comparison + actual_files.sort() + expected_files.sort() + + for expected in expected_files: + assert expected in actual_files + + def test_set_directory_with_invalid_path(self, qtbot): + """Test setting an invalid directory path.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory("/nonexistent/path") + + # Should handle gracefully + assert widget.directory == "/nonexistent/path" + assert widget.model.rowCount() == 0 + + def test_set_directory_with_none(self, qtbot): + """Test setting directory to None.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory(None) + + # Should handle gracefully + assert widget.directory is None + assert widget.model.rowCount() == 0 + + +class TestMacroFunctionParsing: + """Test macro function parsing and AST functionality.""" + + def test_extract_functions_from_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + functions = macro_tree._extract_functions_from_file(test_file) + + # Should extract 2 functions, not the class method + assert len(functions) == 2 + assert "test_macro_function" in functions + assert "another_function" in functions + assert "method" not in functions # Class methods should be excluded + + # Check function details + test_func = functions["test_macro_function"] + assert test_func["line_number"] == 2 # First function starts at line 2 + assert "A test macro function" in test_func["docstring"] + + def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with no functions.""" + empty_file = temp_macro_files / "empty.py" + functions = macro_tree._extract_functions_from_file(empty_file) + + assert len(functions) == 0 + + def test_extract_functions_from_invalid_file(self, macro_tree): + """Test extracting functions from a non-existent file.""" + nonexistent_file = Path("/nonexistent/file.py") + functions = macro_tree._extract_functions_from_file(nonexistent_file) + + assert len(functions) == 0 + + def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with syntax errors.""" + error_file = temp_macro_files / "error_file.py" + functions = macro_tree._extract_functions_from_file(error_file) + + # Should return empty dict on syntax error + assert len(functions) == 0 + + def test_create_file_item(self, macro_tree, temp_macro_files): + """Test creating a file item from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + file_item = macro_tree._create_file_item(test_file) + + assert file_item is not None + assert file_item.text() == "test_macros" + assert file_item.rowCount() == 2 # Should have 2 function children + + # Check file data + file_data = file_item.data(Qt.ItemDataRole.UserRole) + assert file_data["type"] == "file" + assert file_data["file_path"] == str(test_file) + + # Check function children + func_names = [] + for row in range(file_item.rowCount()): + child = file_item.child(row) + func_names.append(child.text()) + + # Check function data + func_data = child.data(Qt.ItemDataRole.UserRole) + assert func_data["type"] == "function" + assert func_data["file_path"] == str(test_file) + assert "function_name" in func_data + assert "line_number" in func_data + + assert "test_macro_function" in func_names + assert "another_function" in func_names + + def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files): + """Test that files starting with underscore are ignored.""" + private_file = temp_macro_files / "_private.py" + file_item = macro_tree._create_file_item(private_file) + + assert file_item is None + + def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files): + """Test that files with no functions return None.""" + empty_file = temp_macro_files / "empty.py" + file_item = macro_tree._create_file_item(empty_file) + + assert file_item is None + + +class TestMacroTreeInteractions: + """Test macro tree widget interactions and signals.""" + + def test_item_click_on_function(self, macro_tree, qtbot): + """Test clicking on a function item.""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a function item + file_item = macro_tree.model.item(0) # First file + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) # First function + func_index = func_item.index() + + # Simulate click + macro_tree._on_item_clicked(func_index) + + # Check signal was emitted + assert len(macro_selected_signals) == 1 + function_name, file_path = macro_selected_signals[0] + assert function_name is not None + assert file_path is not None + assert file_path.endswith(".py") + + def test_item_click_on_file(self, macro_tree, qtbot): + """Test clicking on a file item (should not emit signal).""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a file item + file_item = macro_tree.model.item(0) + if file_item: + file_index = file_item.index() + + # Simulate click + macro_tree._on_item_clicked(file_index) + + # Should not emit signal for file items + assert len(macro_selected_signals) == 0 + + def test_item_double_click_on_function(self, macro_tree, qtbot): + """Test double-clicking on a function item.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Simulate double-click + macro_tree._on_item_double_clicked(func_index) + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + def test_hover_events(self, macro_tree, qtbot): + """Test mouse hover events and action button visibility.""" + # Get the tree view and its viewport + tree_view = macro_tree.tree + viewport = tree_view.viewport() + + # Initially, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + # Find a function item to hover over + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Get the position of the function item + rect = tree_view.visualRect(func_index) + pos = rect.center() + + # Simulate a mouse move event over the item + mouse_event = QMouseEvent( + QEvent.Type.MouseMove, + pos, + tree_view.mapToGlobal(pos), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + # Send the event to the viewport + macro_tree.eventFilter(viewport, mouse_event) + qtbot.wait(100) + + # Now, the hover index should be set + assert macro_tree.delegate.hovered_index.isValid() + assert macro_tree.delegate.hovered_index == func_index + + # Simulate mouse leaving the viewport + leave_event = QEvent(QEvent.Type.Leave) + macro_tree.eventFilter(viewport, leave_event) + qtbot.wait(100) + + # After leaving, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + def test_macro_open_action(self, macro_tree, qtbot): + """Test the macro open action functionality.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item and set it as hovered + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Set the delegate's hovered index and current macro info + macro_tree.delegate.set_hovered_index(func_index) + func_data = func_item.data(Qt.ItemDataRole.UserRole) + macro_tree.delegate.current_macro_info = func_data + + # Trigger the open action + macro_tree._on_macro_open_requested() + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + +class TestMacroTreeRefresh: + """Test macro tree refresh functionality.""" + + def test_refresh(self, macro_tree, temp_macro_files): + """Test refreshing the entire tree.""" + # Get initial count + initial_count = macro_tree.model.rowCount() + + # Add a new macro file + new_file = temp_macro_files / "new_macros.py" + new_file.write_text( + ''' +def new_function(): + """A new function.""" + return "new" +''' + ) + + # Refresh the tree + macro_tree.refresh() + + # Should have one more file + assert macro_tree.model.rowCount() == initial_count + 1 + + def test_refresh_file_item(self, macro_tree, temp_macro_files): + """Test refreshing a single file item.""" + # Find the test_macros.py file + test_file_path = str(temp_macro_files / "test_macros.py") + + # Get initial function count + initial_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + initial_functions.append(child.text()) + break + + # Modify the file to add a new function + with open(test_file_path, "a") as f: + f.write( + ''' + +def newly_added_function(): + """A newly added function.""" + return "added" +''' + ) + + # Refresh just this file + macro_tree.refresh_file_item(test_file_path) + + # Check that the new function was added + updated_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + updated_functions.append(child.text()) + break + + # Should have the new function + assert len(updated_functions) == len(initial_functions) + 1 + assert "newly_added_function" in updated_functions + + def test_refresh_nonexistent_file(self, macro_tree): + """Test refreshing a non-existent file.""" + # Should handle gracefully without crashing + macro_tree.refresh_file_item("/nonexistent/file.py") + + # Tree should remain unchanged + assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash + + def test_expand_collapse_all(self, macro_tree, qtbot): + """Test expand/collapse all functionality.""" + # Initially should be expanded + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + # Items with children should be expanded after initial load + if item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + # Collapse all + macro_tree.collapse_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert not macro_tree.tree.isExpanded(item.index()) + + # Expand all + macro_tree.expand_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + +class TestMacroItemDelegate: + """Test the custom macro item delegate functionality.""" + + def test_delegate_action_management(self, qtbot): + """Test adding and clearing delegate actions.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Should have at least one default action (open) + assert len(widget.delegate.macro_actions) >= 1 + + # Add a custom action + custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget) + widget.add_macro_action(custom_action.action) + + # Should have the additional action + assert len(widget.delegate.macro_actions) >= 2 + + # Clear actions + widget.clear_actions() + + # Should be empty + assert len(widget.delegate.macro_actions) == 0 + + def test_delegate_hover_index_management(self, qtbot): + """Test hover index management in the delegate.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Initially no hover + assert not widget.delegate.hovered_index.isValid() + + # Create a fake index + fake_index = widget.model.createIndex(0, 0) + + # Set hover + widget.delegate.set_hovered_index(fake_index) + assert widget.delegate.hovered_index == fake_index + + # Clear hover + widget.delegate.set_hovered_index(QModelIndex()) + assert not widget.delegate.hovered_index.isValid() diff --git a/tests/unit_tests/test_monaco_dock.py b/tests/unit_tests/test_monaco_dock.py new file mode 100644 index 000000000..0a1d6b88b --- /dev/null +++ b/tests/unit_tests/test_monaco_dock.py @@ -0,0 +1,425 @@ +import os +from typing import Generator +from unittest import mock + +import pytest +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +from .client_mocks import mocked_client + + +@pytest.fixture +def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]: + """Create a MonacoDock for testing.""" + # Mock the macros functionality + mocked_client.macros = mock.MagicMock() + mocked_client.macros._update_handler = mock.MagicMock() + mocked_client.macros._update_handler.get_macros_from_file.return_value = {} + mocked_client.macros._update_handler.get_existing_macros.return_value = {} + + widget = MonacoDock(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestFocusEditor: + def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock): + """Test that last_focused_editor is initially None.""" + assert monaco_dock.last_focused_editor is not None + + def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test setting last_focused_editor when an editor is focused.""" + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) # Wait for the editor to be fully set up + + assert monaco_dock.last_focused_editor is not None + + def test_last_focused_editor_updates_on_focus_change( + self, qtbot, monaco_dock: MonacoDock, tmpdir + ): + """Test that last_focused_editor updates when focus changes.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + assert editor1 != editor2 + assert editor2 is not None + + def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that opening an already open file simply switches focus to it.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + # Re-open file1 + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1_again = monaco_dock.last_focused_editor + + assert editor1 == editor1_again + assert editor1 != editor2 + assert editor2 is not None + + +class TestSaveFiles: + def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving an existing file that is not a macro.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Verify the editor is marked as modified + assert editor_widget.modified + + # Save the file + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName" + ) as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + monaco_dock.save_file() + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a file with macros scope updates macro handler.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Mock macro validation to return True (valid) + with mock.patch.object(monaco_dock, "_validate_macros", return_value=True): + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") # User cancels + # Save the file (should save to existing file, not open dialog) + monaco_dock.save_file() + qtbot.wait(100) + + # Verify macro update methods were called + monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with( + str(file_path) + ) + monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with( + str(file_path) + ) + + def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a macro file with invalid content shows warning.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content to invalid macro + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("exec('print(hello)')") # Invalid macro content + qtbot.wait(100) + + # Mock QMessageBox to capture warning + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning" + ) as mock_warning: + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify validation was called and warning was shown + mock_warning.assert_called_once() + + # Verify file was not saved (content should remain original) + saved_content = file_path.read() + assert saved_content == "def test_function(): pass" + + def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As functionality creates a new file.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('New file content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName + new_file_path = str(tmpdir.join("new_file.py")) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (new_file_path, "Python files (*.py)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify new file was created + assert os.path.exists(new_file_path) + with open(new_file_path, "r", encoding="utf-8") as f: + content = f.read() + assert content == 'print("New file content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + # Verify current_file was updated + assert editor_widget.current_file == new_file_path + + def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As automatically adds .py extension if none provided.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Test content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName to return path without extension + file_path_no_ext = str(tmpdir.join("test_file")) + expected_path = file_path_no_ext + ".py" + + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (file_path_no_ext, "All files (*)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify file was created with .py extension + assert os.path.exists(expected_path) + assert editor_widget.current_file == expected_path + + def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock): + """Test save_file handles case when no editor is focused.""" + # Set last_focused_editor to None + with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None): + # Attempt to save should not raise exception + monaco_dock.save_file() + + def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir): + """Test that macro_file_updated signal is emitted when saving macro files.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Connect signal to capture emission + signal_emitted = [] + monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path)) + + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify signal was emitted + assert len(signal_emitted) == 1 + assert signal_emitted[0] == str(file_path) + + def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that closing a modified file dock asks to save changes.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Mock QMessageBox to simulate user clicking 'Save' + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.StandardButton.Yes + + # Mock QFileDialog.getSaveFileName + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + + # Close the dock; sadly, calling close() alone does not trigger the closeRequested signal + # It is only triggered if the mouse is on top of the tab close button, so we directly call the handler + monaco_dock._on_editor_close_requested( + monaco_dock.last_focused_editor, editor_widget + ) + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + +class TestSignatureHelp: + def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help signal is emitted correctly.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data + signature_data = { + "signatures": [ + { + "label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)", + "documentation": { + "value": "Print objects to the text stream file, separated by sep and followed by end." + }, + } + ], + "activeSignature": 0, + "activeParameter": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct markdown format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature + assert "Print objects to the text stream file" in emitted_signature + + def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock): + """Test signature help with empty signatures.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with no signatures + signature_data = {"signatures": []} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify empty string was emitted + assert len(signature_emitted) == 1 + assert signature_emitted[0] == "" + + def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is missing.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data without documentation + signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with just the function signature + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + + def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is a string instead of dict.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with string documentation + signature_data = { + "signatures": [ + {"label": "function_name(param)", "documentation": "Simple string documentation"} + ], + "activeSignature": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + assert "Simple string documentation" in emitted_signature + + def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help is connected when creating new editors.""" + # Create a new editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + + # Verify the signal connection exists by checking connected signals + # We do this by mocking the signal and verifying the connection + with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler: + # Simulate signature help trigger from the editor + editor_widget.editor.signature_help_triggered.emit({"signatures": []}) + qtbot.wait(100) + + # Verify the handler was called + mock_handler.assert_called_once() diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 149f4d75a..3f1c24fc2 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -1,11 +1,20 @@ +from unittest import mock + import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +from .client_mocks import mocked_client +from .test_scan_control import available_scans_message @pytest.fixture -def monaco_widget(qtbot): - widget = MonacoWidget() +def monaco_widget(qtbot, mocked_client): + widget = MonacoWidget(client=mocked_client) + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -37,3 +46,75 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot): monaco_widget.set_text("Attempting to change text") qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000) assert monaco_widget.get_text() == "Attempting to change text" + + +def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can show the scan control dialog. + """ + + with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog: + monaco_widget._show_scan_control_dialog() + mock_run_dialog.assert_called_once() + + +def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client): + """ + Test that the MonacoWidget can get scan control code from the dialog. + """ + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) + + scan_control_dialog = ScanControlDialog(client=mocked_client) + qtbot.addWidget(scan_control_dialog) + qtbot.waitExposed(scan_control_dialog) + qtbot.wait(300) + + scan_control = scan_control_dialog.scan_control + scan_name = "grid_scan" + kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2} + args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20} + args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10} + mock_slot = mock.MagicMock() + + scan_control.scan_args.connect(mock_slot) + + scan_control.comboBox_scan_selection.setCurrentText(scan_name) + + # Ensure there are two rows in the arg_box + current_rows = scan_control.arg_box.count_arg_rows() + required_rows = 2 + while current_rows < required_rows: + scan_control.arg_box.add_widget_bundle() + current_rows += 1 + + # Set kwargs in the UI + for kwarg_box in scan_control.kwarg_boxes: + for widget in kwarg_box.widgets: + if widget.arg_name in kwargs: + WidgetIO.set_value(widget, kwargs[widget.arg_name]) + + # Set args in the UI for both rows + arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets + num_columns = len(scan_control.arg_box.inputs) + num_rows = int(len(arg_widgets) / num_columns) + assert num_rows == required_rows # We expect 2 rows for grid_scan + + # Set values for first row + for i in range(num_columns): + widget = arg_widgets[i] + arg_name = widget.arg_name + if arg_name in args_row1: + WidgetIO.set_value(widget, args_row1[arg_name]) + + # Set values for second row + for i in range(num_columns): + widget = arg_widgets[num_columns + i] # Next row + arg_name = widget.arg_name + if arg_name in args_row2: + WidgetIO.set_value(widget, args_row2[arg_name]) + + scan_control_dialog.accept() + out = scan_control_dialog.get_scan_code() + + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)" + assert out == expected_code From 0f635433262551f2fee096e17ac864fc39cb133a Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 30 Oct 2025 16:25:24 +0100 Subject: [PATCH 023/155] fix: add metadata to scan control export --- .../widgets/control/scan_control/scan_control.py | 3 ++- .../widgets/editors/monaco/scan_control_dialog.py | 2 +- tests/unit_tests/test_monaco_editor.py | 2 +- tests/unit_tests/test_scan_control.py | 10 ++++++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 8948e4ef5..6bbef6e08 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -467,6 +467,8 @@ def get_scan_parameters(self, bec_object: bool = True): for box in self.kwarg_boxes: box_kwargs = box.get_parameters(bec_object) kwargs.update(box_kwargs) + if self._scan_metadata is not None: + kwargs["metadata"] = self._scan_metadata return args, kwargs def restore_scan_parameters(self, scan_name: str): @@ -519,7 +521,6 @@ def update_scan_metadata(self, md: dict | None): def run_scan(self): """Starts the selected scan with the given parameters.""" args, kwargs = self.get_scan_parameters() - kwargs["metadata"] = self._scan_metadata self.scan_args.emit(args) scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText()) if callable(scan_function): diff --git a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py index 2cbb7121d..f77e62c55 100644 --- a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -89,7 +89,7 @@ def _generate_scan_code(self): # Add keyword arguments (excluding metadata) if processed_kwargs: - kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"] + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items()] all_args.extend(kwargs_strs) # Join all arguments and create the scan call diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 3f1c24fc2..f0b39506b 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -116,5 +116,5 @@ def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, scan_control_dialog.accept() out = scan_control_dialog.get_scan_code() - expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)" + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None, metadata={'sample_name': ''})" assert out == expected_code diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 30ce317d5..75f48af15 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -505,7 +505,13 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client): args, kwargs = scan_control.get_scan_parameters(bec_object=False) assert args == ["samx", 0.0, 2.0] - assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1} + assert kwargs == { + "steps": 10, + "relative": False, + "exp_time": 2.0, + "burst_at_each_point": 1, + "metadata": {"sample_name": ""}, + } TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"} @@ -557,7 +563,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl): scans = SimpleNamespace(grid_scan=MagicMock()) with ( patch.object(scan_control, "scans", scans), - patch.object(scan_control, "get_scan_parameters", lambda: ((), {})), + patch.object(scan_control, "get_scan_parameters", lambda: ((), {"metadata": TEST_MD})), ): scan_control.run_scan() scans.grid_scan.assert_called_once_with(metadata=TEST_MD) From 7983a4527a0bae8a341cbebcb4cf5c611e9c2e25 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 27 Oct 2025 22:04:51 +0100 Subject: [PATCH 024/155] feat(guided_tour): add guided tour --- bec_widgets/utils/guided_tour.py | 735 ++++++++++++++++++++++++++ bec_widgets/utils/toolbars/actions.py | 162 ++++-- tests/unit_tests/test_guided_tour.py | 405 ++++++++++++++ 3 files changed, 1265 insertions(+), 37 deletions(-) create mode 100644 bec_widgets/utils/guided_tour.py create mode 100644 tests/unit_tests/test_guided_tour.py diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py new file mode 100644 index 000000000..4261c703b --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,735 @@ +"""Module providing a guided help system for creating interactive GUI tours.""" + +from __future__ import annotations + +import sys +import weakref +from typing import Callable, Dict, List, TypedDict +from uuid import uuid4 + +import louie +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from louie import saferef +from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal +from qtpy.QtGui import QAction, QColor, QPainter, QPen +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QMenuBar, + QPushButton, + QToolBar, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + +logger = bec_logger.logger + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[ + QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]] + ] + | Callable[[], tuple[QWidget | QAction, str | None]] + | None + ) + text: str + title: str + + +class TutorialOverlay(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # Keep mouse events enabled for the overlay but we'll handle them manually + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self.current_rect = QRect() + self.message_box = self._create_message_box() + self.message_box.hide() + + def _create_message_box(self): + box = QFrame(self) + app = QApplication.instance() + bg_color = app.palette().window().color() + box.setStyleSheet( + f""" + QFrame {{ + background-color: {bg_color.name()}; + border-radius: 8px; + padding: 8px; + }} + """ + ) + layout = QVBoxLayout(box) + + # Top layout with close button (left) and step indicator (right) + top_layout = QHBoxLayout() + + # Close button on the left with material icon + self.close_btn = QPushButton() + self.close_btn.setIcon(material_icon("close")) + self.close_btn.setToolTip("Close") + self.close_btn.setMaximumSize(32, 32) + + # Step indicator on the right + self.step_label = QLabel() + self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;") + + top_layout.addWidget(self.close_btn) + top_layout.addStretch() + top_layout.addWidget(self.step_label) + + # Main content label + self.label = QLabel() + self.label.setWordWrap(True) + + # Bottom navigation buttons + btn_layout = QHBoxLayout() + + # Back button with material icon + self.back_btn = QPushButton("Back") + self.back_btn.setIcon(material_icon("arrow_back")) + + # Next button with material icon + self.next_btn = QPushButton("Next") + self.next_btn.setIcon(material_icon("arrow_forward")) + + btn_layout.addStretch() + btn_layout.addWidget(self.back_btn) + btn_layout.addWidget(self.next_btn) + + layout.addLayout(top_layout) + layout.addWidget(self.label) + layout.addLayout(btn_layout) + return box + + def paintEvent(self, event): # pylint: disable=unused-argument + if not self.current_rect.isValid(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Create semi-transparent overlay color + overlay_color = QColor(0, 0, 0, 160) + # Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive. + r = self.current_rect + rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height() + + # Paint overlay in 4 regions around the highlighted widget using exclusive bounds + # Top region (everything above the highlight) + if rect_y > 0: + top_rect = QRect(0, 0, self.width(), rect_y) + painter.fillRect(top_rect, overlay_color) + + # Bottom region (everything below the highlight) + bottom_y = rect_y + rect_h + if bottom_y < self.height(): + bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y) + painter.fillRect(bottom_rect, overlay_color) + + # Left region (to the left of the highlight) + if rect_x > 0: + left_rect = QRect(0, rect_y, rect_x, rect_h) + painter.fillRect(left_rect, overlay_color) + + # Right region (to the right of the highlight) + right_x = rect_x + rect_w + if right_x < self.width(): + right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h) + painter.fillRect(right_rect, overlay_color) + + # Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap. + border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4) + painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(border_rect, 8, 8) + painter.end() + + def show_step( + self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1 + ): + """ + rect must already be in the overlay's coordinate space (i.e. mapped). + This method positions the message box so it does not overlap the rect. + + Args: + rect(QRect): rectangle to highlight + title(str): Title text for the step + text(str): Main content text for the step + current_step(int): Current step number + total_steps(int): Total number of steps in the tour + """ + self.current_rect = rect + + # Update step indicator in top right + self.step_label.setText(f"Step {current_step} of {total_steps}") + + # Update main content text (without step number since it's now in top right) + content_text = f"{title}
{text}" if title else text + self.label.setText(content_text) + self.message_box.adjustSize() # ensure layout applied + message_size = self.message_box.size() # actual widget size (width, height) + + spacing = 15 + + # Preferred placement: to the right, vertically centered + pos_x = rect.right() + spacing + pos_y = rect.center().y() - (message_size.height() // 2) + + # If it would go off the right edge, try left of the widget + if pos_x + message_size.width() > self.width(): + pos_x = rect.left() - message_size.width() - spacing + # vertical center is still good, but if that overlaps top/bottom we'll clamp below + + # If it goes off the left edge (no space either side), place below, centered horizontally + if pos_x < spacing: + pos_x = rect.center().x() - (message_size.width() // 2) + pos_y = rect.bottom() + spacing + + # If it goes off the bottom, try moving it above the widget + if pos_y + message_size.height() > self.height() - spacing: + # if there's room above the rect, put it there + candidate_y = rect.top() - message_size.height() - spacing + if candidate_y >= spacing: + pos_y = candidate_y + else: + # otherwise clamp to bottom with spacing + pos_y = max(spacing, self.height() - message_size.height() - spacing) + + # If it goes off the top, clamp down + pos_y = max(spacing, pos_y) + + # Make sure we don't poke the left edge + pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing)) + + # Apply geometry and show + self.message_box.setGeometry( + int(pos_x), int(pos_y), message_size.width(), message_size.height() + ) + self.message_box.show() + self.update() + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Resize: + self.setGeometry(obj.rect()) + return False + + +class GuidedTour(QObject): + """ + A guided help system for creating interactive GUI tours. + + Allows developers to register widgets with help text and create guided tours. + """ + + tour_started = Signal() + tour_finished = Signal() + step_changed = Signal(int, int) # current_step, total_steps + + def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True): + super().__init__() + self._visible_check: bool = enforce_visibility + self.main_window_ref = saferef.safe_ref(main_window) + self.overlay = None + self._registered_widgets: Dict[str, TourStep] = {} + self._tour_steps: List[TourStep] = [] + self._current_index = 0 + self._active = False + + @property + def main_window(self) -> QWidget | None: + """Get the main window from weak reference.""" + if self.main_window_ref and callable(self.main_window_ref): + widget = self.main_window_ref() + if isinstance(widget, QWidget): + return widget + return None + + def register_widget( + self, + *, + widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text. + text (str): The help text for the widget. This will be shown during the tour. + title (str, optional): A title for the widget (defaults to its class name or action text). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + # If it's a plain callable + if callable(widget) and not hasattr(widget, "__self__"): + widget_ref = widget + default_title = "Widget" + elif isinstance(widget, QAction): + widget_ref = weakref.ref(widget) + default_title = widget.text() or "Action" + elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button): + + def _resolve_toolbar_button(toolbar_action=widget): + button = toolbar_action.get_toolbar_button() + return (button, None) + + widget_ref = _resolve_toolbar_button + default_title = getattr(widget, "tooltip", "Toolbar Menu") + else: + widget_ref = saferef.safe_ref(widget) + default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget" + + self._registered_widgets[step_id] = { + "widget_ref": widget_ref, + "text": text, + "title": title or default_title, + } + logger.debug(f"Registered widget {title or default_title} with ID {step_id}") + return step_id + + def _action_highlight_rect(self, action: QAction) -> QRect | None: + """ + Try to find the QRect in main_window coordinates that should be highlighted for the given QAction. + Returns None if not found (e.g. not visible). + """ + mw = self.main_window + if mw is None: + return None + # Try toolbars first + for tb in mw.findChildren(QToolBar): + btn = tb.widgetForAction(action) + if btn and btn.isVisible(): + rect = btn.rect() + top_left = btn.mapTo(mw, rect.topLeft()) + return QRect(top_left, rect.size()) + # Try menu bars + menubars = [] + if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)): + mb = mw.menuBar() + if mb and mb not in menubars: + menubars.append(mb) + menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars] + for mb in menubars: + if action in mb.actions(): + ar = mb.actionGeometry(action) + top_left = mb.mapTo(mw, ar.topLeft()) + return QRect(top_left, ar.size()) + return None + + def unregister_widget(self, step_id: str) -> bool: + """ + Unregister a previously registered widget. + + Args: + step_id (str): The unique ID of the registered widget. + + Returns: + bool: True if the widget was unregistered, False if not found. + """ + if self._active: + raise RuntimeError("Cannot unregister widget while tour is active") + if step_id in self._registered_widgets: + if self._registered_widgets[step_id] in self._tour_steps: + self._tour_steps.remove(self._registered_widgets[step_id]) + del self._registered_widgets[step_id] + return True + return False + + def create_tour(self, step_ids: List[str] | None = None) -> bool: + """ + Create a tour from registered widget IDs. + + Args: + step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included. + + Returns: + bool: True if the tour was created successfully, False if any step IDs were not found + """ + if step_ids is None: + step_ids = list(self._registered_widgets.keys()) + + tour_steps = [] + for step_id in step_ids: + if step_id not in self._registered_widgets: + logger.error(f"Step ID {step_id} not found") + return False + tour_steps.append(self._registered_widgets[step_id]) + + self._tour_steps = tour_steps + logger.info(f"Created tour with {len(tour_steps)} steps") + return True + + @SafeSlot() + def start_tour(self): + """Start the guided tour.""" + if not self._tour_steps: + self.create_tour() + + if self._active: + logger.warning("Tour already active") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + return + + self._active = True + self._current_index = 0 + + # Create overlay + self.overlay = TutorialOverlay(main_window) + self.overlay.setGeometry(main_window.rect()) + self.overlay.show() + main_window.installEventFilter(self.overlay) + + # Connect signals + self.overlay.next_btn.clicked.connect(self.next_step) + self.overlay.back_btn.clicked.connect(self.prev_step) + self.overlay.close_btn.clicked.connect(self.stop_tour) + + main_window.installEventFilter(self) + self._show_current_step() + self.tour_started.emit() + logger.info("Started guided tour") + + @SafeSlot() + def stop_tour(self): + """Stop the current tour.""" + if not self._active: + return + + self._active = False + + main_window = self.main_window + if self.overlay and main_window: + main_window.removeEventFilter(self.overlay) + self.overlay.hide() + self.overlay.deleteLater() + self.overlay = None + + if main_window: + main_window.removeEventFilter(self) + self.tour_finished.emit() + logger.info("Stopped guided tour") + + @SafeSlot() + def next_step(self): + """Move to next step or finish tour if on last step.""" + if not self._active: + return + + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + # On last step, finish the tour + self.stop_tour() + + @SafeSlot() + def prev_step(self): + """Move to previous step.""" + if not self._active: + return + + if self._current_index > 0: + self._current_index -= 1 + self._show_current_step() + + def _show_current_step(self): + """Display the current step.""" + if not self._active or not self.overlay: + return + + step = self._tour_steps[self._current_index] + step_title = step["title"] + + target, step_text = self._resolve_step_target(step) + if target is None: + self._advance_past_invalid_step(step_title, reason="Step target no longer exists.") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + self.stop_tour() + return + + highlight_rect = self._get_highlight_rect(main_window, target, step_title) + if highlight_rect is None: + return + + # Calculate step numbers + current_step = self._current_index + 1 + total_steps = len(self._tour_steps) + + self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps) + + # Update button states + self.overlay.back_btn.setEnabled(self._current_index > 0) + + # Update next button text and state + is_last_step = self._current_index >= len(self._tour_steps) - 1 + if is_last_step: + self.overlay.next_btn.setText("Finish") + self.overlay.next_btn.setIcon(material_icon("check")) + self.overlay.next_btn.setEnabled(True) + else: + self.overlay.next_btn.setText("Next") + self.overlay.next_btn.setIcon(material_icon("arrow_forward")) + self.overlay.next_btn.setEnabled(True) + + self.step_changed.emit(self._current_index + 1, len(self._tour_steps)) + + def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]: + """ + Resolve the target widget/action for the given step. + + Args: + step(TourStep): The tour step to resolve. + + Returns: + tuple[QWidget | QAction | None, str]: The resolved target and the step text. + """ + widget_ref = step.get("widget_ref") + step_text = step.get("text", "") + + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + target = widget_ref() + else: + target = widget_ref + + if target is None: + return None, step_text + + if callable(target) and not isinstance(target, (QWidget, QAction)): + result = target() + if isinstance(result, tuple): + target, alt_text = result + if alt_text: + step_text = alt_text + else: + target = result + + return target, step_text + + def _get_highlight_rect( + self, main_window: QWidget, target: QWidget | QAction, step_title: str + ) -> QRect | None: + """ + Get the QRect in main_window coordinates to highlight for the given target. + + Args: + main_window(QWidget): The main window containing the target. + target(QWidget | QAction): The target widget or action to highlight. + step_title(str): The title of the current step (for logging purposes). + + Returns: + QRect | None: The rectangle to highlight, or None if not found/visible. + """ + if isinstance(target, QAction): + rect = self._action_highlight_rect(target) + if rect is None: + self._advance_past_invalid_step( + step_title, + reason=f"Could not find visible widget or menu for QAction {target.text()!r}.", + ) + return None + return rect + + if isinstance(target, QWidget): + if self._visible_check: + if not target.isVisible(): + self._advance_past_invalid_step( + step_title, reason=f"Widget {target!r} is not visible." + ) + return None + rect = target.rect() + top_left = target.mapTo(main_window, rect.topLeft()) + return QRect(top_left, rect.size()) + + self._advance_past_invalid_step( + step_title, reason=f"Unsupported step target type: {type(target)}" + ) + return None + + def _advance_past_invalid_step(self, step_title: str, *, reason: str): + """ + Skip the current step (or stop the tour) when the target cannot be visualised. + """ + logger.warning("%s Skipping step %r.", reason, step_title) + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + + def get_registered_widgets(self) -> Dict[str, TourStep]: + """Get all registered widgets.""" + return self._registered_widgets.copy() + + def clear_registrations(self): + """Clear all registered widgets.""" + if self._active: + self.stop_tour() + self._registered_widgets.clear() + self._tour_steps.clear() + logger.info("Cleared all registrations") + + def set_visibility_enforcement(self, enabled: bool): + """Enable or disable visibility checks when highlighting widgets.""" + self._visible_check = enabled + + def eventFilter(self, obj, event): + """Handle window resize/move events to update step positioning.""" + if event.type() in (QEvent.Type.Move, QEvent.Type.Resize): + if self._active: + self._show_current_step() + return super().eventFilter(obj, event) + + +################################################################################ +############ # Example usage of GuidedTour system ############################## +################################################################################ + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Guided Tour Demo") + central = QWidget() + layout = QVBoxLayout(central) + layout.setSpacing(12) + + layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support.")) + self.btn1 = QPushButton("Primary Button") + self.btn2 = QPushButton("Secondary Button") + self.status_label = QLabel("Use the controls below or the toolbar to interact.") + self.start_tour_btn = QPushButton("Start Guided Tour") + + layout.addWidget(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.status_label) + layout.addStretch() + layout.addWidget(self.start_tour_btn) + self.setCentralWidget(central) + + # Guided tour system + self.guided_help = GuidedTour(self) + + # Menus for demonstrating QAction support in menu bars + self._init_menu_bar() + + # Modular toolbar showcasing QAction targets + self._init_toolbar() + + # Register widgets and actions with help text + primary_step = self.guided_help.register_widget( + widget=self.btn1, + text="The primary button updates the status text when clicked.", + title="Primary Button", + ) + secondary_step = self.guided_help.register_widget( + widget=self.btn2, + text="The secondary button complements the demo layout.", + title="Secondary Button", + ) + toolbar_action_step = self.guided_help.register_widget( + widget=self.toolbar_tour_action.action, + text="Toolbar actions are supported in the guided tour. This one also starts the tour.", + title="Toolbar Tour Action", + ) + tools_menu_step = self.guided_help.register_widget( + widget=self.toolbar.components.get_action("menu_tools"), + text="Expandable toolbar menus group related actions. This button opens the tools menu.", + title="Tools Menu", + ) + + # Create tour from registered widgets + self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step] + widget_ids = self.tour_step_ids + self.guided_help.create_tour(widget_ids) + + # Connect start button + self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + + def _init_menu_bar(self): + menu_bar = self.menuBar() + info_menu = menu_bar.addMenu("Info") + info_menu.setObjectName("info-menu") + self.info_menu = info_menu + self.info_menu_action = info_menu.menuAction() + self.about_action = info_menu.addAction("About This Demo") + + def _init_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + self.addToolBar(self.toolbar) + + self.toolbar_tour_action = MaterialIconAction( + "play_circle", tooltip="Start the guided tour", parent=self + ) + self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action) + + self.toolbar_highlight_action = MaterialIconAction( + "visibility", tooltip="Highlight the primary button", parent=self + ) + self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action) + + demo_bundle = self.toolbar.new_bundle("demo") + demo_bundle.add_action("tour-start") + demo_bundle.add_action("inspect-primary") + + self._setup_tools_menu() + self.toolbar.show_bundles(["demo", "menu_tools"]) + self.toolbar.refresh() + + self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour) + + def _setup_tools_menu(self): + self.tools_menu_actions: dict[str, MaterialIconAction] = { + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add a note", filled=True, parent=self + ), + "bookmark": MaterialIconAction( + icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self + ), + "settings": MaterialIconAction( + icon_name="tune", tooltip="Adjust settings", filled=True, parent=self + ), + } + self.tools_menu_action = ExpandableMenuAction( + label="Tools ", actions=self.tools_menu_actions + ) + self.toolbar.components.add_safe("menu_tools", self.tools_menu_action) + bundle = ToolbarBundle("menu_tools", self.toolbar.components) + bundle.add_action("menu_tools") + self.toolbar.add_bundle(bundle) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + from bec_qthemes import apply_theme + + apply_theme("dark") + w = MainWindow() + w.resize(400, 300) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index dbeb937c3..5c0b0955e 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import weakref from abc import ABC, abstractmethod from contextlib import contextmanager from typing import Dict, Literal @@ -10,7 +11,7 @@ from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QAction, QColor, QIcon +from qtpy.QtGui import QAction, QColor, QIcon # type: ignore from qtpy.QtWidgets import ( QApplication, QComboBox, @@ -52,9 +53,9 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar): btn.setDefaultAction(toolbar_action.action) btn.setAutoRaise(True) if toolbar_action.text_position == "beside": - btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) else: - btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) btn.setText(toolbar_action.label_text) toolbar.addWidget(btn) @@ -65,7 +66,7 @@ class NoCheckDelegate(QStyledItemDelegate): def initStyleOption(self, option, index): super().initStyleOption(option, index) # Remove any check indicator - option.checkState = Qt.Unchecked + option.checkState = Qt.CheckState.Unchecked class LongPressToolButton(QToolButton): @@ -110,13 +111,15 @@ class ToolBarAction(ABC): checkable (bool, optional): Whether the action is checkable. Defaults to False. """ - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): + def __init__( + self, icon_path: str | None = None, tooltip: str | None = None, checkable: bool = False + ): self.icon_path = ( os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None ) - self.tooltip = tooltip - self.checkable = checkable - self.action = None + self.tooltip: str = tooltip or "" + self.checkable: bool = checkable + self.action: QAction @abstractmethod def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): @@ -132,6 +135,11 @@ def cleanup(self): pass +class IconAction(ToolBarAction): + @abstractmethod + def get_icon(self) -> QIcon: ... + + class SeparatorAction(ToolBarAction): """Separator action for the toolbar.""" @@ -139,7 +147,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addSeparator() -class QtIconAction(ToolBarAction): +class QtIconAction(IconAction): def __init__( self, standard_icon, @@ -178,13 +186,13 @@ def get_icon(self): return self.icon -class MaterialIconAction(ToolBarAction): +class MaterialIconAction(IconAction): """ Action with a Material icon for the toolbar. Args: - icon_name (str, optional): The name of the Material icon. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. + icon_name (str): The name of the Material icon. + tooltip (str): The tooltip for the action. checkable (bool, optional): Whether the action is checkable. Defaults to False. filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. @@ -196,8 +204,9 @@ class MaterialIconAction(ToolBarAction): def __init__( self, - icon_name: str = None, - tooltip: str = None, + icon_name: str, + tooltip: str, + *, checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, @@ -216,13 +225,13 @@ def __init__( self.label_text = label_text self.text_position = text_position # Generate the icon using the material_icon helper - self.icon = material_icon( + self.icon: QIcon = material_icon( self.icon_name, size=(20, 20), convert_to_pixmap=False, filled=self.filled, color=self.color, - ) + ) # type: ignore if parent is None: logger.warning( "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." @@ -258,11 +267,11 @@ class DeviceSelectionAction(ToolBarAction): Action for selecting a device in a combobox. Args: - label (str): The label for the combobox. device_combobox (DeviceComboBox): The combobox for selecting the device. + label (str): The label for the combobox. """ - def __init__(self, label: str | None = None, device_combobox=None): + def __init__(self, /, device_combobox: DeviceComboBox, label: str | None = None): super().__init__() self.label = label self.device_combobox = device_combobox @@ -284,7 +293,7 @@ def set_combobox_style(self, color: str): self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") -class SwitchableToolBarAction(ToolBarAction): +class SwitchableToolBarAction(IconAction): """ A split toolbar action that combines a main action and a drop-down menu for additional actions. @@ -304,9 +313,9 @@ class SwitchableToolBarAction(ToolBarAction): def __init__( self, - actions: Dict[str, ToolBarAction], - initial_action: str = None, - tooltip: str = None, + actions: Dict[str, IconAction], + initial_action: str | None = None, + tooltip: str | None = None, checkable: bool = True, default_state_checked: bool = False, parent=None, @@ -329,11 +338,11 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): target (QWidget): The target widget for the action. """ self.main_button = LongPressToolButton(toolbar) - self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.main_button.setCheckable(self.checkable) default_action = self.actions[self.current_key] self.main_button.setIcon(default_action.get_icon()) - self.main_button.setToolTip(default_action.tooltip) + self.main_button.setToolTip(default_action.tooltip or "") self.main_button.clicked.connect(self._trigger_current_action) menu = QMenu(self.main_button) for key, action_obj in self.actions.items(): @@ -431,11 +440,7 @@ class WidgetAction(ToolBarAction): """ def __init__( - self, - label: str | None = None, - widget: QWidget = None, - adjust_size: bool = True, - parent=None, + self, *, widget: QWidget, label: str | None = None, adjust_size: bool = True, parent=None ): super().__init__(icon_path=None, tooltip=label, checkable=False) self.label = label @@ -458,14 +463,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): if self.label is not None: label_widget = QLabel(text=f"{self.label}", parent=target) - label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + label_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + label_widget.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) layout.addWidget(label_widget) if isinstance(self.widget, QComboBox) and self.adjust_size: - self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) + self.widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.widget.setSizePolicy(size_policy) self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) @@ -474,7 +479,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addWidget(self.container) # Store the container as the action to allow toggling visibility. - self.action = self.container + self.action = self.container # type: ignore def cleanup(self): """ @@ -489,7 +494,7 @@ def cleanup(self): @staticmethod def calculate_minimum_width(combo_box: QComboBox) -> int: font_metrics = combo_box.fontMetrics() - max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) + max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) # type: ignore return max_width + 60 @@ -503,9 +508,11 @@ class ExpandableMenuAction(ToolBarAction): icon_path (str, optional): The path to the icon file. Defaults to None. """ - def __init__(self, label: str, actions: dict, icon_path: str = None): + def __init__(self, label: str, actions: dict[str, IconAction], icon_path: str | None = None): super().__init__(icon_path, label) self.actions = actions + self._button_ref: weakref.ReferenceType[QToolButton] | None = None + self._menu_ref: weakref.ReferenceType[QMenu] | None = None def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): button = QToolButton(toolbar) @@ -514,7 +521,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): if self.icon_path: button.setIcon(QIcon(self.icon_path)) button.setText(self.tooltip) - button.setPopupMode(QToolButton.InstantPopup) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) button.setStyleSheet( """ QToolButton { @@ -541,6 +548,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): menu.addAction(action) button.setMenu(menu) toolbar.addWidget(button) + self._button_ref = weakref.ref(button) + self._menu_ref = weakref.ref(menu) + + def get_toolbar_button(self) -> QToolButton | None: + return self._button_ref() if self._button_ref else None + + def get_menu(self) -> QMenu | None: + return self._menu_ref() if self._menu_ref else None class DeviceComboBoxAction(WidgetAction): @@ -587,3 +602,76 @@ def cleanup(self): self.combobox.close() self.combobox.deleteLater() return super().cleanup() + + +class TutorialAction(MaterialIconAction): + """ + Action for starting a guided tutorial/help tour. + + This action automatically initializes a GuidedTour instance and provides + methods to register widgets and start tours. + + Args: + main_window (QWidget): The main window widget for the guided tour overlay. + tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial". + parent (QWidget or None, optional): Parent widget for the underlying QAction. + """ + + def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None): + super().__init__( + icon_name="help", + tooltip=tooltip, + checkable=False, + filled=False, + color=None, + parent=parent, + ) + + from bec_widgets.utils.guided_tour import GuidedTour + + self.guided_help = GuidedTour(main_window) + self.main_window = main_window + + # Connect the action to start the tour + self.action.triggered.connect(self.start_tour) + + def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str: + """ + Register a widget for the guided tour. + + Args: + widget (QWidget): The widget to highlight during the tour. + text (str): The help text to display. + widget_name (str, optional): Optional name for the widget. + + Returns: + str: Unique ID for the registered widget. + """ + return self.guided_help.register_widget(widget=widget, text=text, title=widget_name) + + def start_tour(self): + """Start the guided tour with all registered widgets.""" + registered_widgets = self.guided_help.get_registered_widgets() + if registered_widgets: + # Create tour from all registered widgets + step_ids = list(registered_widgets.keys()) + if self.guided_help.create_tour(step_ids): + self.guided_help.start_tour() + else: + logger.warning("Failed to create guided tour") + else: + logger.warning("No widgets registered for guided tour") + + def has_registered_widgets(self) -> bool: + """Check if any widgets have been registered for the tour.""" + return len(self.guided_help.get_registered_widgets()) > 0 + + def clear_registered_widgets(self): + """Clear all registered widgets.""" + self.guided_help.clear_registrations() + + def cleanup(self): + """Clean up the guided help instance.""" + if hasattr(self, "guided_help"): + self.guided_help.stop_tour() + super().cleanup() diff --git a/tests/unit_tests/test_guided_tour.py b/tests/unit_tests/test_guided_tour.py new file mode 100644 index 000000000..41d3320ad --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,405 @@ +from unittest import mock + +import pytest +from qtpy.QtWidgets import QVBoxLayout, QWidget + +from bec_widgets.utils.guided_tour import GuidedTour +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + + +@pytest.fixture +def main_window(qtbot): + """Create a main window for testing.""" + window = QWidget() + window.resize(800, 600) + qtbot.addWidget(window) + return window + + +@pytest.fixture +def guided_help(main_window): + """Create a GuidedTour instance for testing.""" + return GuidedTour(main_window, enforce_visibility=False) + + +@pytest.fixture +def test_widget(main_window): + """Create a test widget.""" + widget = QWidget(main_window) + widget.resize(100, 50) + widget.show() + return widget + + +class DummyWidget(QWidget): + """A dummy widget for testing purposes.""" + + def isVisible(self) -> bool: + """Override isVisible to always return True for testing.""" + return True + + +class TestGuidedTour: + """Test the GuidedTour class core functionality.""" + + def test_initialization(self, guided_help): + """Test GuidedTour is properly initialized.""" + assert guided_help.main_window is not None + assert guided_help._registered_widgets == {} + assert guided_help._tour_steps == [] + assert guided_help._current_index == 0 + assert guided_help._active is False + + def test_register_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration creates weak references.""" + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget", title="TestWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Test widget" + assert registered["title"] == "TestWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]() is test_widget + + def test_register_widget_auto_name(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration with automatic naming.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help._registered_widgets[widget_id] + assert registered["title"] == "QWidget" + + def test_create_tour_valid_ids(self, guided_help: GuidedTour, test_widget: QWidget): + """Test creating tour with valid widget IDs.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + result = guided_help.create_tour([widget_id]) + + assert result is True + assert len(guided_help._tour_steps) == 1 + assert guided_help._tour_steps[0]["text"] == "Test widget" + + def test_create_tour_invalid_ids(self, guided_help: GuidedTour): + """Test creating tour with invalid widget IDs.""" + result = guided_help.create_tour(["invalid_id"]) + + assert result is False + assert len(guided_help._tour_steps) == 0 + + def test_start_tour_no_steps(self, guided_help: GuidedTour, test_widget: QWidget): + """Test starting tour with no steps will add all registered widgets.""" + # Register a widget + guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert len(guided_help._tour_steps) == 1 + + def test_start_tour_success(self, guided_help: GuidedTour, test_widget: QWidget): + """Test successful tour start.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert guided_help.overlay is not None + + def test_stop_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test stopping a tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.stop_tour() + + assert guided_help._active is False + + def test_next_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to next step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + + assert guided_help._current_index == 0 + + guided_help.next_step() + + assert guided_help._current_index == 1 + + def test_next_step_finish_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test next step on last step finishes tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.next_step() + + assert guided_help._active is False + + def test_prev_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to previous step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + guided_help.next_step() + + assert guided_help._current_index == 1 + + guided_help.prev_step() + + assert guided_help._current_index == 0 + + def test_get_registered_widgets(self, guided_help: GuidedTour, test_widget: QWidget): + """Test getting registered widgets.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help.get_registered_widgets() + + assert widget_id in registered + assert registered[widget_id]["text"] == "Test widget" + + def test_clear_registrations(self, guided_help: GuidedTour, test_widget: QWidget): + """Test clearing all registrations.""" + guided_help.register_widget(widget=test_widget, text="Test widget") + + guided_help.clear_registrations() + + assert len(guided_help._registered_widgets) == 0 + assert len(guided_help._tour_steps) == 0 + + def test_weak_reference_main_window(self, main_window: QWidget): + """Test that main window is stored as weak reference.""" + guided_help = GuidedTour(main_window) + + # Should be able to get main window through weak reference + assert guided_help.main_window is not None + assert guided_help.main_window == main_window + + def test_complete_tour_flow(self, guided_help: GuidedTour, test_widget: QWidget): + """Test complete tour workflow.""" + # Create widgets + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + # Register widgets + id1 = guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + id2 = guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + # Create and start tour + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + + # Move through tour + guided_help.next_step() + assert guided_help._current_index == 1 + + # Finish tour + guided_help.next_step() + assert guided_help._active is False + + def test_finish_button_on_last_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that the Next button changes to Finish on the last step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + + # First step should show "Next" + assert "Next" in overlay.next_btn.text() + + # Navigate to last step + guided_help.next_step() + + # Last step should show "Finish" + assert "Finish" in overlay.next_btn.text() + + def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that step counter is properly displayed.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + assert overlay.step_label.text() == "Step 1 of 2" + + def test_register_expandable_menu_action(self, qtbot): + """Ensure toolbar menu actions can be registered directly.""" + window = QWidget() + layout = QVBoxLayout(window) + toolbar = ModularToolBar(parent=window) + layout.addWidget(toolbar) + qtbot.addWidget(window) + + tools_action = ExpandableMenuAction( + label="Tools ", + actions={ + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add note", filled=True, parent=window + ) + }, + ) + toolbar.components.add_safe("menu_tools", tools_action) + bundle = toolbar.new_bundle("menu_tools") + bundle.add_action("menu_tools") + toolbar.show_bundles(["menu_tools"]) + + guided = GuidedTour(window, enforce_visibility=False) + guided.register_widget(widget=tools_action, text="Toolbar tools menu") + guided.start_tour() + + assert guided._active is True + + @mock.patch("bec_widgets.utils.guided_tour.logger") + def test_error_handling(self, mock_logger, guided_help): + """Test error handling and logging.""" + # Test with invalid step ID + result = guided_help.create_tour(["invalid_id"]) + assert result is False + mock_logger.error.assert_called() + + def test_memory_safety_widget_deletion(self, guided_help: GuidedTour, test_widget: QWidget): + """Test memory safety when widget is deleted.""" + widget = QWidget(test_widget) + + # Register widget + widget_id = guided_help.register_widget(widget=widget, text="Test widget") + + # Verify weak reference works + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is widget + + # Delete widget + widget.close() + widget.setParent(None) + del widget + + # The weak reference should now return None + # This tests that our weak reference implementation is working + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is None + + def test_unregister_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test unregistering a widget.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + assert widget_id not in guided_help._registered_widgets + + def test_unregister_nonexistent_widget(self, guided_help: GuidedTour): + """Test unregistering a widget that does not exist.""" + # Should not raise an error + assert guided_help.unregister_widget("nonexistent_id") is False + + def test_unregister_widget_removes_from_tour( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget also removes it from the tour steps.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + # The tour steps should no longer contain the unregistered widget + assert len(guided_help._tour_steps) == 0 + + def test_unregister_widget_during_tour_raises( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget during an active tour raises an error.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + with pytest.raises(RuntimeError): + guided_help.unregister_widget(widget_id) + + def test_register_lambda_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a lambda function as a widget.""" + widget_id = guided_help.register_widget( + widget=lambda: (test_widget, "test text"), text="Lambda widget", title="LambdaWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Lambda widget" + assert registered["title"] == "LambdaWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "test text" + + def test_register_widget_local_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a local function as a widget.""" + + def local_widget_function(): + return test_widget, "local text" + + widget_id = guided_help.register_widget( + widget=local_widget_function, text="Local function widget", title="LocalWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Local function widget" + assert registered["title"] == "LocalWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "local text" + + def test_text_accepts_html_content(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """Test that registered text can contain HTML content.""" + html_text = ( + "Bold Text with Italics and a
link." + ) + widget_id = guided_help.register_widget( + widget=test_widget, text=html_text, title="HTMLWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == html_text + + def test_overlay_painter(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """ + Test that the overlay painter works without errors. + While we cannot directly test the visual output, we can ensure + that calling the paintEvent does not raise exceptions. + """ + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget for overlay", title="OverlayWidget" + ) + widget = guided_help._registered_widgets[widget_id]["widget_ref"]() + with mock.patch.object(widget, "isVisible", return_value=True): + guided_help.start_tour() + guided_help.overlay.paintEvent(None) # Force paint event to render text + qtbot.wait(300) # Wait for rendering From 5567274f2dbb60bf5b61d4b80ff9a4f7b0c833bf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 28 Oct 2025 20:39:19 +0100 Subject: [PATCH 025/155] fix(becconnector): ophyd thread killer on exit + in conftest --- bec_widgets/utils/bec_connector.py | 11 +++++++++++ tests/unit_tests/conftest.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index b670d03a7..9c820c7a0 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -129,6 +129,17 @@ def __init__( def terminate(client=self.client, dispatcher=self.bec_dispatcher): logger.info("Disconnecting", repr(dispatcher)) dispatcher.disconnect_all() + + try: # shutdown ophyd threads if any + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() + logger.info("Ophyd dispatcher shut down successfully.") + except Exception as e: + logger.warning( + f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}" + ) + logger.info("Shutting down BEC Client", repr(client)) client.shutdown() diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index bfd7ddb64..47a7a1c76 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -41,6 +41,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus # if the test failed, we don't want to check for open widgets as # it simply pollutes the output + # stop pyepics dispatcher for leaking tests + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() if request.node.stash._storage.get("failed"): print("Test failed, skipping cleanup checks") return From 5836f286dee6b6a6229436fbbaf0fd249ec6cff2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 20 Oct 2025 13:42:31 +0200 Subject: [PATCH 026/155] feat(bec_widget): save screenshot to bytes --- bec_widgets/utils/bec_widget.py | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ef397d03c..1660f58c0 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -6,7 +6,8 @@ import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger -from qtpy.QtCore import QObject +from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -57,7 +58,6 @@ def __init__( theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the widget's apply_theme method will be called when the theme changes. """ - super().__init__( client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs ) @@ -218,6 +218,44 @@ def screenshot(self, file_name: str | None = None): screenshot.save(file_name) logger.info(f"Screenshot saved to {file_name}") + def screenshot_bytes( + self, + *, + max_width: int | None = None, + max_height: int | None = None, + fmt: str = "PNG", + quality: int = -1, + ) -> QByteArray: + """ + Grab this widget, optionally scale to a max size, and return encoded image bytes. + + If max_width/max_height are omitted (the default), capture at full resolution. + + Args: + max_width(int, optional): Maximum width of the screenshot. + max_height(int, optional): Maximum height of the screenshot. + fmt(str, optional): Image format (e.g., "PNG", "JPEG"). + quality(int, optional): Image quality (0-100), -1 for default. + + Returns: + QByteArray: The screenshot image bytes. + """ + if not isinstance(self, QWidget): + return QByteArray() + pixmap: QPixmap = self.grab() + if pixmap.isNull(): + return QByteArray() + if max_width is not None or max_height is not None: + w = max_width if max_width is not None else pixmap.width() + h = max_height if max_height is not None else pixmap.height() + pixmap = pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QIODevice.WriteOnly) + pixmap.save(buf, fmt, quality) + buf.close() + return ba + def attach(self): dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) if dock is None: From 73afb5a4726c0ba98efc41ffdd028b37594460c9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 20:32:39 +0100 Subject: [PATCH 027/155] fix(widget_state_manager): added shiboken check --- bec_widgets/utils/widget_state_manager.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 2efe56e78..e5f94bdc5 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings from qtpy.QtWidgets import ( @@ -84,6 +85,9 @@ def _save_widget_state_qsettings( settings(QSettings): The QSettings object to save the state to. recursive(bool): Whether to recursively save the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -115,11 +119,14 @@ def _save_widget_state_qsettings( ) # to avoid duplicates for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): self._save_widget_state_qsettings(child, settings, False) + logger.info(f"Saved state for widget '{widget_name}'") def _load_widget_state_qsettings( self, widget: QWidget, settings: QSettings, recursive: bool = True @@ -132,6 +139,9 @@ def _load_widget_state_qsettings( settings(QSettings): The QSettings object to load the state from. recursive(bool): Whether to recursively load the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -156,7 +166,9 @@ def _load_widget_state_qsettings( ) # to avoid duplicates for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): From af8f3911aad35ec8ced21444367016ee3ae23c95 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 20:32:52 +0100 Subject: [PATCH 028/155] fix(dark_mode_button): skip settings added --- .../widgets/utility/visual/dark_mode_button/dark_mode_button.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py index 6fdb1f15a..840e56ad4 100644 --- a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +++ b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py @@ -23,6 +23,7 @@ def __init__( **kwargs, ) -> None: super().__init__(parent=parent, client=client, gui_id=gui_id, theme_update=True, **kwargs) + self.setProperty("skip_settings", True) self._dark_mode_enabled = False self.layout = QHBoxLayout(self) From c204815c4237e3322b77ab9435e3c1d5686a88bc Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 26 Oct 2025 20:00:00 +0100 Subject: [PATCH 029/155] fix(main_window): cleanup adjusted with shiboken6 --- bec_widgets/widgets/containers/main_window/main_window.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index cead68c10..c1a86eaa6 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -2,6 +2,7 @@ import os +import shiboken6 from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEvent, QSize, Qt, QTimer from qtpy.QtGui import QAction, QActionGroup, QIcon @@ -414,9 +415,9 @@ def cleanup(self): for child in children: ancestor = WidgetHierarchy._get_becwidget_ancestor(child) if ancestor is self: - child.cleanup() - child.close() - child.deleteLater() + if shiboken6.isValid(child): + child.close() + child.deleteLater() # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): From 5865d0f97d1335919b8711df360996ee855999c3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 6 Oct 2025 13:37:52 +0200 Subject: [PATCH 030/155] feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted --- .../advanced_dock_area/advanced_dock_area.py | 444 +++++---- .../advanced_dock_area/profile_utils.py | 455 +++++++++- .../advanced_dock_area/settings/__init__.py | 0 .../advanced_dock_area/settings/dialogs.py | 325 +++++++ .../settings/workspace_manager.py | 404 +++++++++ .../advanced_dock_area/states/user/test.ini | 234 ----- .../toolbar_components/workspace_actions.py | 133 +-- tests/unit_tests/test_advanced_dock_area.py | 853 ++++++++++++++++-- 8 files changed, 2288 insertions(+), 560 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py delete mode 100644 bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 8bdee4a7e..489d24512 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -4,18 +4,15 @@ from typing import Literal, cast import PySide6QtAds as QtAds +from bec_lib import bec_logger from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import Signal +from qtpy.QtCore import QTimer, Signal +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( QApplication, - QCheckBox, QDialog, - QHBoxLayout, QInputDialog, - QLabel, - QLineEdit, QMessageBox, - QPushButton, QSizePolicy, QVBoxLayout, QWidget, @@ -37,14 +34,30 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( SETTINGS_KEYS, - is_profile_readonly, - list_profiles, - open_settings, - profile_path, + default_profile_path, + get_last_profile, + is_quick_select, + load_default_profile_screenshot, + load_user_profile_screenshot, + now_iso_utc, + open_default_settings, + open_user_settings, + profile_origin, + profile_origin_display, read_manifest, - set_profile_readonly, + restore_user_from_default, + set_last_profile, + set_quick_select, + user_profile_path, write_manifest, ) +from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( + RestoreProfileDialog, + SaveProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( + WorkSpaceManager, +) from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import ( WorkspaceConnection, workspace_bundle, @@ -65,6 +78,8 @@ from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton +logger = bec_logger.logger + class DockSettingsDialog(QDialog): @@ -79,62 +94,6 @@ def __init__(self, parent: QWidget, target: QWidget): layout.addWidget(self.prop_editor) -class SaveProfileDialog(QDialog): - """Dialog for saving workspace profiles with read-only option.""" - - def __init__(self, parent: QWidget, current_name: str = ""): - super().__init__(parent) - self.setWindowTitle("Save Workspace Profile") - self.setModal(True) - self.resize(400, 150) - layout = QVBoxLayout(self) - - # Name input - name_row = QHBoxLayout() - name_row.addWidget(QLabel("Profile Name:")) - self.name_edit = QLineEdit(current_name) - self.name_edit.setPlaceholderText("Enter profile name...") - name_row.addWidget(self.name_edit) - layout.addLayout(name_row) - - # Read-only checkbox - self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)") - layout.addWidget(self.readonly_checkbox) - - # Info label - info_label = QLabel("Read-only profiles are protected from modification and deletion.") - info_label.setStyleSheet("color: gray; font-size: 10px;") - layout.addWidget(info_label) - - # Buttons - btn_row = QHBoxLayout() - btn_row.addStretch(1) - self.save_btn = QPushButton("Save") - self.save_btn.setDefault(True) - cancel_btn = QPushButton("Cancel") - self.save_btn.clicked.connect(self.accept) - cancel_btn.clicked.connect(self.reject) - btn_row.addWidget(self.save_btn) - btn_row.addWidget(cancel_btn) - layout.addLayout(btn_row) - - # Enable/disable save button based on name input - self.name_edit.textChanged.connect(self._update_save_button) - self._update_save_button() - - def _update_save_button(self): - """Enable save button only when name is not empty.""" - self.save_btn.setEnabled(bool(self.name_edit.text().strip())) - - def get_profile_name(self) -> str: - """Get the entered profile name.""" - return self.name_edit.text().strip() - - def is_readonly(self) -> bool: - """Check if the profile should be marked as read-only.""" - return self.readonly_checkbox.isChecked() - - class AdvancedDockArea(BECWidget, QWidget): RPC = True PLUGIN = False @@ -151,6 +110,7 @@ class AdvancedDockArea(BECWidget, QWidget): # Define a signal for mode changes mode_changed = Signal(str) + profile_changed = Signal(str) def __init__( self, @@ -190,12 +150,18 @@ def __init__( self._setup_toolbar() self._hook_toolbar() + # Popups + self.save_dialog = None + self.manage_dialog = None + # Place toolbar and dock manager into layout self._root_layout.addWidget(self.toolbar) self._root_layout.addWidget(self.dock_manager, 1) # Populate and hook the workspace combo self._refresh_workspace_list() + self._current_profile_name = None + self._pending_autosave_skip: tuple[str, str] | None = None # State manager self.state_manager = WidgetStateManager(self) @@ -205,12 +171,29 @@ def __init__( # Initialize default editable state based on current lock self._set_editable(True) # default to editable; will sync toolbar toggle below - # Sync Developer toggle icon state after initial setup - dev_action = self.toolbar.components.get_action("developer_mode").action - dev_action.setChecked(self._editable) + # Sync Developer toggle icon state after initial setup #TODO temporary disable + # dev_action = self.toolbar.components.get_action("developer_mode").action + # dev_action.setChecked(self._editable) # Apply the requested mode after everything is set up self.mode = mode + QTimer.singleShot( + 0, self._fetch_initial_profile + ) # To allow full init before loading profile and prevent segfault on exit + + def _fetch_initial_profile(self): + # Restore last-used profile if available; otherwise fall back to combo selection + combo = self.toolbar.components.get_action("workspace_combo").widget + last = get_last_profile() + if last and ( + os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last)) + ): + init_profile = last + else: + init_profile = combo.currentText() + if init_profile: + self.load_profile(init_profile) + combo.setCurrentText(init_profile) def _make_dock( self, @@ -408,18 +391,18 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) self.toolbar.components.add_safe( "dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self) ) - # Developer mode toggle (moved from menu into toolbar) - self.toolbar.components.add_safe( - "developer_mode", - MaterialIconAction( - icon_name="code", tooltip="Developer Mode", checkable=True, parent=self - ), - ) + # Developer mode toggle (moved from menu into toolbar) #TODO temporary disable + # self.toolbar.components.add_safe( + # "developer_mode", + # MaterialIconAction( + # icon_name="code", tooltip="Developer Mode", checkable=True, parent=self + # ), + # ) bda = ToolbarBundle("dock_actions", self.toolbar.components) bda.add_action("attach_all") bda.add_action("screenshot") bda.add_action("dark_mode") - bda.add_action("developer_mode") + # bda.add_action("developer_mode") #TODO temporary disable self.toolbar.add_bundle(bda) # Default bundle configuration (show menus by default) @@ -485,20 +468,15 @@ def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str] self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) - # Developer mode toggle - self.toolbar.components.get_action("developer_mode").action.toggled.connect( - self._on_developer_mode_toggled - ) + # Developer mode toggle #TODO temporary disable + # self.toolbar.components.get_action("developer_mode").action.toggled.connect( + # self._on_developer_mode_toggled + # ) def _set_editable(self, editable: bool) -> None: self.lock_workspace = not editable self._editable = editable - # Sync the toolbar lock toggle with current mode - lock_action = self.toolbar.components.get_action("lock").action - lock_action.setChecked(not editable) - lock_action.setVisible(editable) - attach_all_action = self.toolbar.components.get_action("attach_all").action attach_all_action.setVisible(editable) @@ -517,8 +495,8 @@ def _set_editable(self, editable: bool) -> None: else: self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) - # Keep Developer mode UI in sync - self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + # Keep Developer mode UI in sync #TODO temporary disable + # self.toolbar.components.get_action("developer_mode").action.setChecked(editable) def _on_developer_mode_toggled(self, checked: bool) -> None: """Handle developer mode checkbox toggle.""" @@ -689,63 +667,64 @@ def lock_workspace(self, value: bool): self._locked = value self._apply_dock_lock(value) self.toolbar.components.get_action("save_workspace").action.setVisible(not value) - self.toolbar.components.get_action("delete_workspace").action.setVisible(not value) for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: + settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) + settings.setValue(SETTINGS_KEYS["state"], b"") + settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) + self.dock_manager.addPerspective(self.windowTitle()) + self.dock_manager.savePerspectives(settings) + self.state_manager.save_state(settings=settings) + write_manifest(settings, self.dock_list()) + if save_preview: + ba = self.screenshot_bytes() + if ba and len(ba) > 0: + settings.setValue(SETTINGS_KEYS["screenshot"], ba) + settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc()) + + logger.info(f"Workspace snapshot written to settings: {settings.fileName()}") + @SafeSlot(str) def save_profile(self, name: str | None = None): """ Save the current workspace profile. + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + Read-only bundled profiles cannot be overwritten. + Args: - name (str | None): The name of the profile. If None, a dialog will prompt for a name. + name (str | None): The name of the profile to save. If None, prompts the user. """ - if not name: - # Use the new SaveProfileDialog instead of QInputDialog - dialog = SaveProfileDialog(self) - if dialog.exec() != QDialog.Accepted: - return - name = dialog.get_profile_name() - readonly = dialog.is_readonly() - - # Check if profile already exists and is read-only - if os.path.exists(profile_path(name)) and is_profile_readonly(name): - suggested_name = f"{name}_custom" - reply = QMessageBox.warning( - self, - "Read-only Profile", - f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n" - f"Would you like to save it with a different name?\n" - f"Suggested name: '{suggested_name}'", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.Yes, - ) - if reply == QMessageBox.Yes: - # Show dialog again with suggested name pre-filled - dialog = SaveProfileDialog(self, suggested_name) - if dialog.exec() != QDialog.Accepted: - return - name = dialog.get_profile_name() - readonly = dialog.is_readonly() - - # Check again if the new name is also read-only (recursive protection) - if os.path.exists(profile_path(name)) and is_profile_readonly(name): - return self.save_profile() - else: - return - else: - # If name is provided directly, assume not read-only unless already exists - readonly = False - if os.path.exists(profile_path(name)) and is_profile_readonly(name): - QMessageBox.warning( - self, - "Read-only Profile", - f"The profile '{name}' is marked as read-only and cannot be overwritten.", - QMessageBox.Ok, - ) - return + def _profile_exists(profile_name: str) -> bool: + return profile_origin(profile_name) != "unknown" + + initial_name = name or "" + quickselect_default = is_quick_select(name) if name else False + + current_profile = getattr(self, "_current_profile_name", "") or "" + dialog = SaveProfileDialog( + self, + current_name=initial_name, + current_profile_name=current_profile, + name_exists=_profile_exists, + profile_origin=profile_origin, + origin_label=profile_origin_display, + quick_select_checked=quickselect_default, + ) + if dialog.exec() != QDialog.Accepted: + return + + name = dialog.get_profile_name() + quickselect = dialog.is_quick_select() + origin_before_save = profile_origin(name) + overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" # Display saving placeholder workspace_combo = self.toolbar.components.get_action("workspace_combo").widget workspace_combo.blockSignals(True) @@ -753,42 +732,75 @@ def save_profile(self, name: str | None = None): workspace_combo.setCurrentIndex(0) workspace_combo.blockSignals(False) - # Save the profile - settings = open_settings(name) - settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) - settings.setValue( - SETTINGS_KEYS["state"], b"" - ) # No QMainWindow state; placeholder for backward compat - settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) - self.dock_manager.addPerspective(name) - self.dock_manager.savePerspectives(settings) - self.state_manager.save_state(settings=settings) - write_manifest(settings, self.dock_list()) - - # Set read-only status if specified - if readonly: - set_profile_readonly(name, readonly) + # Create or update default copy controlled by overwrite flag + should_write_default = overwrite_default or not os.path.exists(default_profile_path(name)) + if should_write_default: + ds = open_default_settings(name) + self._write_snapshot_to_settings(ds) + if not ds.value(SETTINGS_KEYS["created_at"], ""): + ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + # Ensure new profiles are not quick-select by default + if not ds.value(SETTINGS_KEYS["is_quick_select"], None): + ds.setValue(SETTINGS_KEYS["is_quick_select"], False) + + # Always (over)write the user copy + us = open_user_settings(name) + self._write_snapshot_to_settings(us) + if not us.value(SETTINGS_KEYS["created_at"], ""): + us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + # Ensure new profiles are not quick-select by default (only if missing) + if not us.value(SETTINGS_KEYS["is_quick_select"], None): + us.setValue(SETTINGS_KEYS["is_quick_select"], False) + + # set quick select + if quickselect: + set_quick_select(name, quickselect) - settings.sync() self._refresh_workspace_list() + if current_profile and current_profile != name and not dialog.overwrite_existing: + self._pending_autosave_skip = (current_profile, name) + else: + self._pending_autosave_skip = None workspace_combo.setCurrentText(name) + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=name) def load_profile(self, name: str | None = None): """ Load a workspace profile. - Args: - name (str | None): The name of the profile. If None, a dialog will prompt for a name. + Before switching, persist the current profile to the user copy. + Prefer loading the user copy; fall back to the default copy. """ - # FIXME this has to be tweaked - if not name: + if not name: # Gui fallback if the name is not provided name, ok = QInputDialog.getText( self, "Load Workspace", "Enter the name of the workspace profile to load:" ) if not ok or not name: return - settings = open_settings(name) + prev_name = getattr(self, "_current_profile_name", None) + skip_pair = getattr(self, "_pending_autosave_skip", None) + if prev_name and prev_name != name: + if skip_pair and skip_pair == (prev_name, name): + self._pending_autosave_skip = None + else: + us_prev = open_user_settings(prev_name) + self._write_snapshot_to_settings(us_prev, save_preview=False) + + # Choose source settings: user first, else default + if os.path.exists(user_profile_path(name)): + settings = open_user_settings(name) + elif os.path.exists(default_profile_path(name)): + settings = open_default_settings(name) + else: + QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") + return + + # Rebuild widgets and restore states for item in read_manifest(settings): obj_name = item["object_name"] widget_class = item["widget_class"] @@ -806,8 +818,6 @@ def load_profile(self, name: str | None = None): geom = settings.value(SETTINGS_KEYS["geom"]) if geom: self.restoreGeometry(geom) - # No window state for QWidget-based host; keep for backwards compat read - # window_state = settings.value(SETTINGS_KEYS["state"]) # ignored dock_state = settings.value(SETTINGS_KEYS["ads_state"]) if dock_state: self.dock_manager.restoreState(dock_state) @@ -815,6 +825,42 @@ def load_profile(self, name: str | None = None): self.state_manager.load_state(settings=settings) self._set_editable(self._editable) + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=name) + + @SafeSlot() + @SafeSlot(str) + def restore_user_profile_from_default(self, name: str | None = None): + """ + Overwrite the user copy of *name* with the default baseline. + If *name* is None, target the currently active profile. + + Args: + name (str | None): The name of the profile to restore. If None, uses the current profile. + """ + target = name or getattr(self, "_current_profile_name", None) + if not target: + return + + current_pixmap = None + if self.isVisible(): + current_pixmap = QPixmap() + ba = bytes(self.screenshot_bytes()) + current_pixmap.loadFromData(ba) + if current_pixmap is None or current_pixmap.isNull(): + current_pixmap = load_user_profile_screenshot(target) + default_pixmap = load_default_profile_screenshot(target) + + if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap): + return + + restore_user_from_default(target) + self.delete_all() + self.load_profile(target) + @SafeSlot() def delete_profile(self): """ @@ -825,17 +871,6 @@ def delete_profile(self): if not name: return - # Check if profile is read-only - if is_profile_readonly(name): - QMessageBox.warning( - self, - "Read-only Profile", - f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n" - f"Read-only profiles are protected from modification and deletion.", - QMessageBox.Ok, - ) - return - # Confirm deletion for regular profiles reply = QMessageBox.question( self, @@ -848,7 +883,7 @@ def delete_profile(self): if reply != QMessageBox.Yes: return - file_path = profile_path(name) + file_path = user_profile_path(name) try: os.remove(file_path) except FileNotFoundError: @@ -860,15 +895,69 @@ def _refresh_workspace_list(self): Populate the workspace combo box with all saved profile names (without .ini). """ combo = self.toolbar.components.get_action("workspace_combo").widget + active_profile = getattr(self, "_current_profile_name", None) if hasattr(combo, "refresh_profiles"): - combo.refresh_profiles() + combo.refresh_profiles(active_profile) else: # Fallback for regular QComboBox + from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + list_quick_profiles, + ) + combo.blockSignals(True) combo.clear() - combo.addItems(list_profiles()) + quick_profiles = list_quick_profiles() + items = list(quick_profiles) + if active_profile and active_profile not in items: + items.insert(0, active_profile) + combo.addItems(items) + if active_profile: + idx = combo.findText(active_profile) + if idx >= 0: + combo.setCurrentIndex(idx) + if active_profile and active_profile not in quick_profiles: + combo.setToolTip("Active profile is not in quick select") + else: + combo.setToolTip("") combo.blockSignals(False) + ################################################################################ + # Dialog Popups + ################################################################################ + + @SafeSlot() + def show_workspace_manager(self): + """ + Show the workspace manager dialog. + """ + manage_action = self.toolbar.components.get_action("manage_workspaces").action + if self.manage_dialog is None or not self.manage_dialog.isVisible(): + self.manage_widget = WorkSpaceManager( + self, target_widget=self, default_profile=self._current_profile_name + ) + self.manage_dialog = QDialog(modal=False) + + self.manage_dialog.setWindowTitle("Workspace Manager") + self.manage_dialog.setMinimumSize(1200, 500) + self.manage_dialog.layout = QVBoxLayout(self.manage_dialog) + self.manage_dialog.layout.addWidget(self.manage_widget) + self.manage_dialog.finished.connect(self._manage_dialog_closed) + self.manage_dialog.show() + self.manage_dialog.resize(300, 300) + manage_action.setChecked(True) + else: + # If already open, bring it to the front + self.manage_dialog.raise_() + self.manage_dialog.activateWindow() + manage_action.setChecked(True) # keep it toggle + + def _manage_dialog_closed(self): + self.manage_widget.close() + self.manage_widget.deleteLater() + self.manage_dialog.deleteLater() + self.manage_dialog = None + self.toolbar.components.get_action("manage_workspaces").action.setChecked(False) + ################################################################################ # Mode Switching ################################################################################ @@ -913,6 +1002,15 @@ def cleanup(self): """ Cleanup the dock area. """ + # before cleanup save current profile (user copy) + name = getattr(self, "_current_profile_name", None) + if name: + us = open_user_settings(name) + self._write_snapshot_to_settings(us) + set_last_profile(name) + if self.manage_dialog is not None: + self.manage_dialog.reject() + self.manage_dialog = None self.delete_all() self.dark_mode_button.close() self.dark_mode_button.deleteLater() @@ -920,7 +1018,7 @@ def cleanup(self): super().cleanup() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 47fe1ddd7..a49183ffe 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -1,21 +1,181 @@ +""" +Utilities for managing AdvancedDockArea profiles stored in INI files. + +Policy: +- All created/modified profiles are stored under the BEC settings root: /profiles/{default,user} +- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to. +- Lookup order when reading: user → settings default → app or plugin bundled default. +""" + +from __future__ import annotations + import os +import shutil +from functools import lru_cache +from pathlib import Path +from typing import Literal +from bec_lib.client import BECClient +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from pydantic import BaseModel, Field from PySide6QtAds import CDockWidget -from qtpy.QtCore import QSettings +from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt +from qtpy.QtGui import QPixmap MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default") -_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user") + +ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] + + +def module_profiles_dir() -> str: + """Return the read-only module-bundled profiles directory (no writes here).""" + return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles") + + +@lru_cache(maxsize=1) +def _plugin_repo_root() -> Path | None: + try: + return Path(plugin_repo_path()) + except ValueError: + return None + + +@lru_cache(maxsize=1) +def _plugin_display_name() -> str | None: + repo_root = _plugin_repo_root() + if not repo_root: + return None + repo_name = repo_root.name + if repo_name: + return repo_name + try: + pkg = plugin_package_name() + except ValueError: + return None + return pkg.split(".")[0] if pkg else None + + +@lru_cache(maxsize=1) +def plugin_profiles_dir() -> str | None: + """Return the read-only plugin-bundled profiles directory if available.""" + repo_root = _plugin_repo_root() + if not repo_root: + return None + + candidates = [repo_root.joinpath("bec_widgets", "profiles")] + try: + package_root = repo_root.joinpath(*plugin_package_name().split(".")) + candidates.append(package_root.joinpath("bec_widgets", "profiles")) + except ValueError: + pass + + for candidate in candidates: + if candidate.is_dir(): + return str(candidate) + return None + + +def _settings_profiles_root() -> str: + """Return the writable profiles root provided by BEC client (or env fallback).""" + client = BECClient() + bec_widgets_settings = client._service_config.config.get("bec_widgets_settings") + bec_widgets_setting_path = ( + bec_widgets_settings.get("base_path") if bec_widgets_settings else None + ) + default_path = os.path.join(bec_widgets_setting_path, "profiles") + root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path) + os.makedirs(root, exist_ok=True) + return root + + +def default_profiles_dir() -> str: + path = os.path.join(_settings_profiles_root(), "default") + os.makedirs(path, exist_ok=True) + return path -def profiles_dir() -> str: - path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR) +def user_profiles_dir() -> str: + path = os.path.join(_settings_profiles_root(), "user") os.makedirs(path, exist_ok=True) return path -def profile_path(name: str) -> str: - return os.path.join(profiles_dir(), f"{name}.ini") +def default_profile_path(name: str) -> str: + return os.path.join(default_profiles_dir(), f"{name}.ini") + + +def user_profile_path(name: str) -> str: + return os.path.join(user_profiles_dir(), f"{name}.ini") + + +def module_profile_path(name: str) -> str: + return os.path.join(module_profiles_dir(), f"{name}.ini") + + +def plugin_profile_path(name: str) -> str | None: + directory = plugin_profiles_dir() + if not directory: + return None + return os.path.join(directory, f"{name}.ini") + + +def profile_origin(name: str) -> ProfileOrigin: + """ + Determine where a profile originates from. + + Returns: + ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles, + "settings" for user-defined ones, and "unknown" if no backing files are found. + """ + if os.path.exists(module_profile_path(name)): + return "module" + plugin_path = plugin_profile_path(name) + if plugin_path and os.path.exists(plugin_path): + return "plugin" + if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)): + return "settings" + return "unknown" + + +def is_profile_read_only(name: str) -> bool: + """Return True when the profile originates from bundled module or plugin directories.""" + return profile_origin(name) in {"module", "plugin"} + + +def profile_origin_display(name: str) -> str | None: + """Return a human-readable label for the profile's origin.""" + origin = profile_origin(name) + if origin == "module": + return "BEC Widgets" + if origin == "plugin": + return _plugin_display_name() + if origin == "settings": + return "User" + return None + + +def delete_profile_files(name: str) -> bool: + """ + Delete the profile files from the writable settings directories. + + Removes both the user and default copies (if they exist) and clears the last profile + metadata when applicable. Returns True when at least one file was removed. + """ + if is_profile_read_only(name): + return False + + removed = False + for path in {user_profile_path(name), default_profile_path(name)}: + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if removed and get_last_profile() == name: + set_last_profile(None) + + return removed SETTINGS_KEYS = { @@ -23,29 +183,90 @@ def profile_path(name: str) -> str: "state": "mainWindow/State", "ads_state": "mainWindow/DockingState", "manifest": "manifest/widgets", - "readonly": "profile/readonly", + "created_at": "profile/created_at", + "is_quick_select": "profile/quick_select", + "screenshot": "profile/screenshot", + "screenshot_at": "profile/screenshot_at", + "last_profile": "app/last_profile", } def list_profiles() -> list[str]: - return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini")) + # Collect profiles from writable settings (default + user) + defaults = { + os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini") + } + users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")} + + # Also consider read-only defaults from core module and beamline plugin repositories + read_only_sources: dict[str, tuple[str, str]] = {} + sources: list[tuple[str, str | None]] = [ + ("module", module_profiles_dir()), + ("plugin", plugin_profiles_dir()), + ] + for origin, directory in sources: + if not directory or not os.path.isdir(directory): + continue + for filename in os.listdir(directory): + if not filename.endswith(".ini"): + continue + name, _ = os.path.splitext(filename) + read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) + for name, (_origin, src) in sorted(read_only_sources.items()): + # Ensure a copy in the settings default directory so existing code paths work unchanged + dst_default = default_profile_path(name) + if not os.path.exists(dst_default): + os.makedirs(os.path.dirname(dst_default), exist_ok=True) + shutil.copyfile(src, dst_default) + # Ensure a user copy exists to allow edits in the writable settings area + dst_user = user_profile_path(name) + if not os.path.exists(dst_user): + os.makedirs(os.path.dirname(dst_user), exist_ok=True) + shutil.copyfile(src, dst_user) + # Minimal metadata touch-up to align with existing expectations + s = open_user_settings(name) + if not s.value(SETTINGS_KEYS["created_at"], ""): + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) -def is_profile_readonly(name: str) -> bool: - """Check if a profile is marked as read-only.""" - settings = open_settings(name) - return settings.value(SETTINGS_KEYS["readonly"], False, type=bool) + defaults |= set(read_only_sources.keys()) + users |= set(read_only_sources.keys()) + # Return union of all discovered names + return sorted(defaults | users) -def set_profile_readonly(name: str, readonly: bool) -> None: - """Set the read-only status of a profile.""" - settings = open_settings(name) - settings.setValue(SETTINGS_KEYS["readonly"], readonly) - settings.sync() +def open_default_settings(name: str) -> QSettings: + return QSettings(default_profile_path(name), QSettings.IniFormat) -def open_settings(name: str) -> QSettings: - return QSettings(profile_path(name), QSettings.IniFormat) + +def open_user_settings(name: str) -> QSettings: + return QSettings(user_profile_path(name), QSettings.IniFormat) + + +def _app_settings() -> QSettings: + """Return app-wide settings file for AdvancedDockArea metadata.""" + return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) + + +def get_last_profile() -> str | None: + """Return the last-used profile name if stored, else None.""" + s = _app_settings() + name = s.value(SETTINGS_KEYS["last_profile"], "", type=str) + return name or None + + +def set_last_profile(name: str | None) -> None: + """Persist the last-used profile name (or clear it if None).""" + s = _app_settings() + if name: + s.setValue(SETTINGS_KEYS["last_profile"], name) + else: + s.remove(SETTINGS_KEYS["last_profile"]) + + +def now_iso_utc() -> str: + return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: @@ -77,3 +298,197 @@ def read_manifest(settings: QSettings) -> list[dict]: ) settings.endArray() return items + + +def restore_user_from_default(name: str) -> None: + """Overwrite the user profile with the default baseline (keep default intact).""" + src = default_profile_path(name) + dst = user_profile_path(name) + if not os.path.exists(src): + return + preserve_quick_select = is_quick_select(name) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(src, dst) + s = open_user_settings(name) + if not s.value(SETTINGS_KEYS["created_at"], ""): + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if preserve_quick_select: + s.setValue(SETTINGS_KEYS["is_quick_select"], True) + + +def is_quick_select(name: str) -> bool: + """Return True if profile is marked to appear in quick-select combo.""" + s = ( + open_user_settings(name) + if os.path.exists(user_profile_path(name)) + else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None) + ) + if s is None: + return False + return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) + + +def set_quick_select(name: str, enabled: bool) -> None: + """Set/unset the quick-select flag on the USER copy (creates it if missing).""" + s = open_user_settings(name) + s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) + + +def list_quick_profiles() -> list[str]: + """List only profiles that have quick-select enabled (user wins over default).""" + names = list_profiles() + return [n for n in names if is_quick_select(n)] + + +def _file_modified_iso(path: str) -> str: + try: + mtime = os.path.getmtime(path) + return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) + except Exception: + return now_iso_utc() + + +def _manifest_count(settings: QSettings) -> int: + n = settings.beginReadArray(SETTINGS_KEYS["manifest"]) + settings.endArray() + return int(n or 0) + + +def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: + data = settings.value(SETTINGS_KEYS["screenshot"], None) + if not data: + return None + + buf = None + if isinstance(data, QByteArray): + buf = data + elif isinstance(data, (bytes, bytearray, memoryview)): + buf = bytes(data) + elif isinstance(data, str): + try: + buf = QByteArray(data.encode("latin-1")) + except Exception: + buf = None + + if buf is None: + return None + + pm = QPixmap() + ok = pm.loadFromData(buf) + return pm if ok and not pm.isNull() else None + + +class ProfileInfo(BaseModel): + name: str + author: str = "BEC Widgets" + notes: str = "" + created: str = Field(default_factory=now_iso_utc) + modified: str = Field(default_factory=now_iso_utc) + is_quick_select: bool = False + widget_count: int = 0 + size_kb: int = 0 + user_path: str = "" + default_path: str = "" + origin: ProfileOrigin = "unknown" + is_read_only: bool = False + + +def get_profile_info(name: str) -> ProfileInfo: + """ + Return merged metadata for a profile as a validated Pydantic model. + Prefers the USER copy; falls back to DEFAULT if the user copy is missing. + """ + u_path = user_profile_path(name) + d_path = default_profile_path(name) + origin = profile_origin(name) + prefer_user = os.path.exists(u_path) + read_only = origin in {"module", "plugin"} + s = ( + open_user_settings(name) + if prefer_user + else (open_default_settings(name) if os.path.exists(d_path) else None) + ) + if s is None: + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = "" + return ProfileInfo( + name=name, + author=author, + notes="", + created=now_iso_utc(), + modified=now_iso_utc(), + is_quick_select=False, + widget_count=0, + size_kb=0, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc() + src_path = u_path if prefer_user else d_path + modified = _file_modified_iso(src_path) + count = _manifest_count(s) + try: + size_kb = int(os.path.getsize(src_path) / 1024) + except Exception: + size_kb = 0 + settings_author = s.value("profile/author", "", type=str) or None + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = settings_author or "user" + + return ProfileInfo( + name=name, + author=author, + notes=s.value("profile/notes", "", type=str) or "", + created=created, + modified=modified, + is_quick_select=is_quick_select(name), + widget_count=count, + size_kb=size_kb, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + +def load_profile_screenshot(name: str) -> QPixmap | None: + """Load the stored screenshot pixmap for a profile from settings (user preferred).""" + u_path = user_profile_path(name) + d_path = default_profile_path(name) + s = ( + open_user_settings(name) + if os.path.exists(u_path) + else (open_default_settings(name) if os.path.exists(d_path) else None) + ) + if s is None: + return None + return _load_screenshot_from_settings(s) + + +def load_user_profile_screenshot(name: str) -> QPixmap | None: + """Load the screenshot from the user profile copy, if available.""" + if not os.path.exists(user_profile_path(name)): + return None + return _load_screenshot_from_settings(open_user_settings(name)) + + +def load_default_profile_screenshot(name: str) -> QPixmap | None: + """Load the screenshot from the default profile copy, if available.""" + if not os.path.exists(default_profile_path(name)): + return None + return _load_screenshot_from_settings(open_default_settings(name)) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py new file mode 100644 index 000000000..e9d60f3f0 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from typing import Callable, Literal + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeSlot + + +class SaveProfileDialog(QDialog): + """Dialog for saving workspace profiles with quick select option.""" + + def __init__( + self, + parent: QWidget | None = None, + current_name: str = "", + current_profile_name: str = "", + *, + name_exists: Callable[[str], bool] | None = None, + profile_origin: ( + Callable[[str], Literal["module", "plugin", "settings", "unknown"]] | None + ) = None, + origin_label: Callable[[str], str | None] | None = None, + quick_select_checked: bool = False, + ): + super().__init__(parent) + self.setWindowTitle("Save Workspace Profile") + self.setModal(True) + self.resize(400, 160) + + self._name_exists = name_exists or (lambda _: False) + self._profile_origin = profile_origin or (lambda _: "unknown") + self._origin_label = origin_label or (lambda _: None) + self._current_profile_name = current_profile_name.strip() + self._previous_name_before_overwrite = current_name + self._block_name_signals = False + self._block_checkbox_signals = False + self.overwrite_existing = False + + layout = QVBoxLayout(self) + + # Name input + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Profile Name:")) + self.name_edit = QLineEdit(current_name) + self.name_edit.setPlaceholderText("Enter profile name...") + name_row.addWidget(self.name_edit) + layout.addLayout(name_row) + + # Overwrite checkbox + self.overwrite_checkbox = QCheckBox("Overwrite current profile") + self.overwrite_checkbox.setEnabled(bool(self._current_profile_name)) + self.overwrite_checkbox.toggled.connect(self._on_overwrite_toggled) + layout.addWidget(self.overwrite_checkbox) + + # Quick-select checkbox + self.quick_select_checkbox = QCheckBox("Include in quick selection.") + self.quick_select_checkbox.setChecked(quick_select_checked) + layout.addWidget(self.quick_select_checkbox) + + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch(1) + self.save_btn = QPushButton("Save") + self.save_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + self.save_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self.save_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Enable/disable save button based on name input + self.name_edit.textChanged.connect(self._on_name_changed) + self._update_save_button() + + @SafeSlot(bool) + def _on_overwrite_toggled(self, checked: bool): + if self._block_checkbox_signals: + return + if not self._current_profile_name: + return + + self._block_name_signals = True + if checked: + self._previous_name_before_overwrite = self.name_edit.text() + self.name_edit.setText(self._current_profile_name) + self.name_edit.selectAll() + else: + if self.name_edit.text().strip() == self._current_profile_name: + self.name_edit.setText(self._previous_name_before_overwrite or "") + self._block_name_signals = False + self._update_save_button() + + @SafeSlot(str) + def _on_name_changed(self, _: str): + if self._block_name_signals: + return + text = self.name_edit.text().strip() + if self.overwrite_checkbox.isChecked() and text != self._current_profile_name: + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + self._update_save_button() + + def _update_save_button(self): + """Enable save button only when name is not empty.""" + self.save_btn.setEnabled(bool(self.name_edit.text().strip())) + + def get_profile_name(self) -> str: + """Return the entered profile name.""" + return self.name_edit.text().strip() + + def is_quick_select(self) -> bool: + """Return whether the profile should appear in quick select.""" + return self.quick_select_checkbox.isChecked() + + def _generate_unique_name(self, base: str) -> str: + candidate_base = base.strip() or "profile" + suffix = "_custom" + candidate = f"{candidate_base}{suffix}" + counter = 1 + while self._name_exists(candidate) or self._profile_origin(candidate) != "unknown": + candidate = f"{candidate_base}{suffix}_{counter}" + counter += 1 + return candidate + + def accept(self): + name = self.get_profile_name() + if not name: + return + + self.overwrite_existing = False + origin = self._profile_origin(name) + if origin in {"module", "plugin"}: + source_label = self._origin_label(name) + if origin == "module": + provider = source_label or "BEC Widgets" + else: + provider = ( + f"the {source_label} plugin repository" + if source_label + else "the plugin repository" + ) + QMessageBox.information( + self, + "Read-only profile", + ( + f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n" + "Please choose a different name." + ), + ) + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + if origin == "settings": + reply = QMessageBox.question( + self, + "Overwrite profile", + ( + f"A profile named '{name}' already exists.\n\n" + "Overwriting will update both the saved profile and its restore default.\n" + "Do you want to continue?" + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + self.overwrite_existing = True + + super().accept() + + +class PreviewPanel(QGroupBox): + """Resizable preview pane that scales its pixmap with aspect ratio preserved.""" + + def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None): + super().__init__(title, parent) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None + + layout = QVBoxLayout(self) + # layout.setContentsMargins(0,0,0,0) # leave room for group title and frame + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setMinimumSize(360, 240) + self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + layout.addWidget(self.image_label, 1) + + if self._original: + self._update_scaled_pixmap() + else: + self.image_label.setText("No preview available") + self.image_label.setStyleSheet( + self.image_label.styleSheet() + "color: rgba(255,255,255,0.6); font-style: italic;" + ) + + def setPixmap(self, pixmap: QPixmap | None): + self._original = pixmap if (pixmap and not pixmap.isNull()) else None + if self._original: + self.image_label.setText("") + self._update_scaled_pixmap() + else: + self.image_label.setPixmap(QPixmap()) + self.image_label.setText("No preview available") + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._original: + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self): + if not self._original: + return + size = self.image_label.size() + if size.width() <= 0 or size.height() <= 0: + return + scaled = self._original.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.image_label.setPixmap(scaled) + + +class RestoreProfileDialog(QDialog): + """ + Confirmation dialog that previews the current profile screenshot against the default baseline. + """ + + def __init__( + self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ): + super().__init__(parent) + self.setWindowTitle("Restore Profile to Default") + self.setModal(True) + self.resize(880, 480) + + layout = QVBoxLayout(self) + + info_label = QLabel( + "Restoring will discard your custom layout and replace it with the default profile." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + preview_row = QHBoxLayout() + layout.addLayout(preview_row) + + current_preview = PreviewPanel("Current", current_pixmap, self) + default_preview = PreviewPanel("Default", default_pixmap, self) + + # Equal expansion left/right + preview_row.addWidget(current_preview, 1) + + arrow_label = QLabel("\u2192") + arrow_label.setAlignment(Qt.AlignCenter) + arrow_label.setStyleSheet("font-size: 32px; padding: 0 16px;") + arrow_label.setMinimumWidth(40) + arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + preview_row.addWidget(arrow_label) + + preview_row.addWidget(default_preview, 1) + + # Enforce equal stretch for both previews + preview_row.setStretch(0, 1) + preview_row.setStretch(1, 0) + preview_row.setStretch(2, 1) + + warn_label = QLabel( + "This action cannot be undone. Do you want to restore the default layout now?" + ) + warn_label.setWordWrap(True) + layout.addWidget(warn_label) + + btn_row = QHBoxLayout() + btn_row.addStretch(1) + restore_btn = QPushButton("Restore") + restore_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + restore_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(restore_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Make the previews take most of the vertical space on resize + layout.setStretch(0, 0) # info label + layout.setStretch(1, 1) # preview row + layout.setStretch(2, 0) # warning label + layout.setStretch(3, 0) # buttons + + @staticmethod + def confirm( + parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ) -> bool: + dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap) + return dialog.exec() == QDialog.Accepted diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py new file mode 100644 index 000000000..936227479 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from functools import partial + +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget, SafeSlot +from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + delete_profile_files, + get_profile_info, + is_quick_select, + list_profiles, + load_profile_screenshot, + set_quick_select, +) + + +class WorkSpaceManager(BECWidget, QWidget): + RPC = False + PLUGIN = False + COL_ACTIONS = 0 + COL_NAME = 1 + COL_AUTHOR = 2 + HEADERS = ["Actions", "Profile", "Author"] + + def __init__( + self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.target_widget = target_widget + self.accent_colors = get_accent_colors() + self._init_ui() + if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): + self.target_widget.profile_changed.connect(self.on_profile_changed) + if default_profile is not None: + self._select_by_name(default_profile) + self._show_profile_details(default_profile) + + def _init_ui(self): + self.root_layout = QHBoxLayout(self) + self.splitter = QSplitter(Qt.Horizontal, self) + self.root_layout.addWidget(self.splitter) + + # Init components + self._init_profile_table() + self._init_profile_details_tree() + self._init_screenshot_preview() + + # Build two-column layout + left_col = QVBoxLayout() + left_col.addWidget(self.profile_table, 1) + left_col.addWidget(self.profile_details_tree, 0) + + self.save_profile_button = QPushButton("Save current layout as new profile", self) + self.save_profile_button.clicked.connect(self.save_current_as_profile) + left_col.addWidget(self.save_profile_button) + self.save_profile_button.setEnabled(self.target_widget is not None) + + # Wrap left widgets into a panel that participates in splitter sizing + left_panel = QWidget(self) + left_panel.setLayout(left_col) + left_panel.setMinimumWidth(220) + + # Make the screenshot preview expand to fill remaining space + self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.right_box = QGroupBox("Profile Screenshot Preview", self) + right_col = QVBoxLayout(self.right_box) + right_col.addWidget(self.screenshot_label, 1) + + self.splitter.addWidget(left_panel) + self.splitter.addWidget(self.right_box) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([350, 650]) + + def _init_profile_table(self): + self.profile_table = QTableWidget(self) + self.profile_table.setColumnCount(len(self.HEADERS)) + self.profile_table.setHorizontalHeaderLabels(self.HEADERS) + self.profile_table.setAlternatingRowColors(True) + self.profile_table.verticalHeader().setVisible(False) + + # Enforce row selection, single-select, and disable edits + self.profile_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.profile_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.profile_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + + # Ensure the table expands to use vertical space in the left panel + self.profile_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + header = self.profile_table.horizontalHeader() + header.setStretchLastSection(False) + header.setDefaultAlignment(Qt.AlignCenter) + + class _CenterDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignCenter + + self.profile_table.setItemDelegate(_CenterDelegate(self.profile_table)) + + header.setSectionResizeMode(self.COL_ACTIONS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_AUTHOR, QHeaderView.ResizeToContents) + self.render_table() + self.profile_table.itemSelectionChanged.connect(self._on_table_selection_changed) + self.profile_table.cellClicked.connect(self._on_cell_clicked) + + def _init_profile_details_tree(self): + self.profile_details_tree = QTreeWidget(self) + self.profile_details_tree.setHeaderLabels(["Field", "Value"]) + # Keep details compact so the table can expand + self.profile_details_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + def _init_screenshot_preview(self): + self.screenshot_label = QLabel(self) + self.screenshot_label.setMinimumHeight(160) + self.screenshot_label.setAlignment(Qt.AlignCenter) + + def render_table(self): + self.profile_table.setRowCount(0) + for profile in list_profiles(): + self._add_profile_row(profile) + + def _add_profile_row(self, name: str): + row = self.profile_table.rowCount() + self.profile_table.insertRow(row) + + actions_items = QWidget(self) + actions_items.profile_name = name + actions_items_layout = QHBoxLayout(actions_items) + actions_items_layout.setContentsMargins(0, 0, 0, 0) + + info = get_profile_info(name) + + # Flags + is_active = ( + self.target_widget is not None + and getattr(self.target_widget, "_current_profile_name", None) == name + ) + quick = info.is_quick_select + is_read_only = info.is_read_only + + # Play (green if active) + self._make_action_button( + actions_items, + "play_circle", + "Switch to this profile", + self.switch_profile, + filled=is_active, + color=(self.accent_colors.success if is_active else None), + ) + + # Quick-select (yellow if enabled) + self._make_action_button( + actions_items, + "star", + "Include in quick selection", + self.toggle_quick_select, + filled=quick, + color=(self.accent_colors.warning if quick else None), + ) + + # Delete (red, disabled when read-only) + delete_button = self._make_action_button( + actions_items, + "delete", + "Delete this profile", + self.delete_profile, + color=self.accent_colors.emergency, + ) + if is_read_only: + delete_button.setEnabled(False) + delete_button.setToolTip("Bundled profiles are read-only and cannot be deleted.") + + actions_items_layout.addStretch() + + self.profile_table.setCellWidget(row, self.COL_ACTIONS, actions_items) + self.profile_table.setItem(row, self.COL_NAME, QTableWidgetItem(name)) + self.profile_table.setItem(row, self.COL_AUTHOR, QTableWidgetItem(info.author)) + + def _make_action_button( + self, + parent: QWidget, + icon_name: str, + tooltip: str, + slot: callable, + *, + filled: bool = False, + color: str | None = None, + ): + button = QToolButton(parent=parent) + button.setIcon(material_icon(icon_name, filled=filled, color=color)) + button.setToolTip(tooltip) + button.clicked.connect(partial(slot, parent.profile_name)) + parent.layout().addWidget(button) + return button + + def _select_by_name(self, name: str) -> None: + for row in range(self.profile_table.rowCount()): + item = self.profile_table.item(row, self.COL_NAME) + if item and item.text() == name: + self.profile_table.selectRow(row) + break + + def _current_selected_profile(self) -> str | None: + rows = self.profile_table.selectionModel().selectedRows() + if not rows: + return None + row = rows[0].row() + item = self.profile_table.item(row, self.COL_NAME) + return item.text() if item else None + + def _show_profile_details(self, name: str) -> None: + info = get_profile_info(name) + self.profile_details_tree.clear() + entries = [ + ("Name", info.name), + ("Author", info.author or ""), + ("Created", info.created or ""), + ("Modified", info.modified or ""), + ("Quick select", "Yes" if info.is_quick_select else "No"), + ("Widgets", str(info.widget_count)), + ("Size (KB)", str(info.size_kb)), + ("User path", info.user_path or ""), + ("Default path", info.default_path or ""), + ] + for k, v in entries: + self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v])) + self.profile_details_tree.expandAll() + + # Render screenshot preview from profile INI + pm = load_profile_screenshot(name) + if pm is not None and not pm.isNull(): + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) + else: + self.screenshot_label.setPixmap(QPixmap()) + + @SafeSlot() + def _on_table_selection_changed(self): + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot(int, int) + def _on_cell_clicked(self, row: int, column: int): + item = self.profile_table.item(row, self.COL_NAME) + if item: + self._show_profile_details(item.text()) + + ################################################## + # Public Slots + ################################################## + @SafeSlot(str) + def on_profile_changed(self, name: str): + """Keep the manager in sync without forcing selection to the active profile.""" + selected = self._current_selected_profile() + self.render_table() + if selected: + self._select_by_name(selected) + self._show_profile_details(selected) + + @SafeSlot(str) + def switch_profile(self, profile_name: str): + self.target_widget.load_profile(profile_name) + try: + self.target_widget.toolbar.components.get_action( + "workspace_combo" + ).widget.setCurrentText(profile_name) + except Exception as e: + print(f"Warning: Could not update workspace combo box. {e}") + pass + self.render_table() + self._select_by_name(profile_name) + self._show_profile_details(profile_name) + + @SafeSlot(str) + def toggle_quick_select(self, profile_name: str): + enabled = is_quick_select(profile_name) + set_quick_select(profile_name, not enabled) + self.render_table() + if self.target_widget is not None: + self.target_widget._refresh_workspace_list() + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot() + def save_current_as_profile(self): + if self.target_widget is None: + QMessageBox.information( + self, + "Save Profile", + "No workspace is associated with this manager. Attach a workspace to save profiles.", + ) + return + + self.target_widget.save_profile() + # AdvancedDockArea will emit profile_changed which will trigger table refresh, + # but ensure the UI stays in sync even if the signal is delayed. + self.render_table() + current = getattr(self.target_widget, "_current_profile_name", None) + if current: + self._select_by_name(current) + self._show_profile_details(current) + + @SafeSlot(str) + def delete_profile(self, profile_name: str): + info = get_profile_info(profile_name) + if info.is_read_only: + QMessageBox.information( + self, "Delete Profile", "This profile is read-only and cannot be deleted." + ) + return + + reply = QMessageBox.question( + self, + "Delete Profile", + ( + f"Delete the profile '{profile_name}'?\n\n" + "This will remove both the user and default copies." + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + try: + removed = delete_profile_files(profile_name) + except OSError as exc: + QMessageBox.warning( + self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" + ) + return + + if not removed: + QMessageBox.information( + self, "Delete Profile", "No writable profile files were found to delete." + ) + return + + if self.target_widget is not None: + if getattr(self.target_widget, "_current_profile_name", None) == profile_name: + self.target_widget._current_profile_name = None + if hasattr(self.target_widget, "_refresh_workspace_list"): + self.target_widget._refresh_workspace_list() + + self.render_table() + remaining_profiles = list_profiles() + if remaining_profiles: + next_profile = remaining_profiles[0] + self._select_by_name(next_profile) + self._show_profile_details(next_profile) + else: + self.profile_details_tree.clear() + self.screenshot_label.setPixmap(QPixmap()) + + def resizeEvent(self, event): + super().resizeEvent(event) + name = self._current_selected_profile() + if not name: + return + pm = load_profile_screenshot(name) + if pm is None or pm.isNull(): + return + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini deleted file mode 100644 index 6188162c0..000000000 --- a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini +++ /dev/null @@ -1,234 +0,0 @@ -[BECMainWindowNoRPC.AdvancedDockArea] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 29 2075 974) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_workspace=false -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mode=developer -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle=Advanced Dock Area - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -compact_view=false -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -expand_popup=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 1252 897) -hide_toolbar=false -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -label=BEC Queue -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -tooltip=BEC Queue status -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -auto_range_x=true -auto_range_y=true -baseSize=@Size(0 0) -color_palette=plasma -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -curve_json=[] -enable_fps_monitor=false -enable_popups=true -enable_side_panel=false -enable_toolbar=true -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 798 897) -inner_axes=true -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -legend_label_size=9 -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_aspect_ratio=false -max_dataset_size_mb=10 -maximumSize=@Size(16777215 16777215) -minimal_crosshair_precision=3 -minimumSize=@Size(0 0) -mouseTracking=false -outer_axes=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -skip_large_dataset_check=false -skip_large_dataset_warning=false -statusTip= -styleSheet= -tabletTracking=false -title= -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= -x_entry= -x_grid=false -x_label= -x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -x_log=false -x_mode=auto -y_grid=false -y_label= -y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -y_log=false - -[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -dark_mode_enabled=false -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 40 40) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(40 40) -minimumSize=@Size(40 40) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] -autoRepeat=true -checkable=false -checked=false -enabled=true -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" -iconText=Dock settings -iconVisibleInMenu=false -menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) -priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) -shortcut= -shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) -shortcutVisibleInContextMenu=false -statusTip= -text=Dock settings -toolTip=Dock settings -visible=true -whatsThis= - -[Perspectives] -1\Name=test -1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -size=1 - -[mainWindow] -DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) -State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) - -[manifest] -widgets\1\closable=true -widgets\1\floatable=true -widgets\1\movable=true -widgets\1\object_name=BECQueue -widgets\1\widget_class=BECQueue -widgets\2\closable=true -widgets\2\floatable=true -widgets\2\movable=true -widgets\2\object_name=PositionerBox -widgets\2\widget_class=PositionerBox -widgets\3\closable=true -widgets\3\floatable=true -widgets\3\movable=true -widgets\3\object_name=Waveform -widgets\3\widget_class=Waveform -widgets\size=3 diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index 616dcc08c..bdfb9a5a2 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -1,17 +1,14 @@ from __future__ import annotations -from bec_qthemes import material_icon from qtpy.QtCore import Qt -from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QComboBox, QSizePolicy from bec_widgets import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - is_profile_readonly, - list_profiles, -) +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles class ProfileComboBox(QComboBox): @@ -21,22 +18,47 @@ def __init__(self, parent=None): super().__init__(parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - def refresh_profiles(self): - """Refresh the profile list with appropriate icons.""" + def refresh_profiles(self, active_profile: str | None = None): + """ + Refresh the profile list and ensure the active profile is visible. + + Args: + active_profile(str | None): The currently active profile name. + """ - current_text = self.currentText() + current_text = active_profile or self.currentText() self.blockSignals(True) self.clear() - lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False) - - for profile in list_profiles(): - if is_profile_readonly(profile): - self.addItem(lock_icon, f"{profile}") - # Set tooltip for read-only profiles - self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole) - else: - self.addItem(profile) + quick_profiles = list_quick_profiles() + quick_set = set(quick_profiles) + + items = list(quick_profiles) + if active_profile and active_profile not in quick_set: + items.insert(0, active_profile) + + for profile in items: + self.addItem(profile) + idx = self.count() - 1 + + # Reset any custom styling + self.setItemData(idx, None, Qt.FontRole) + self.setItemData(idx, None, Qt.ToolTipRole) + self.setItemData(idx, None, Qt.ForegroundRole) + + if active_profile and profile == active_profile: + tooltip = "Active workspace profile" + if profile not in quick_set: + font = QFont(self.font()) + font.setItalic(True) + font.setBold(True) + self.setItemData(idx, font, Qt.FontRole) + self.setItemData(idx, self.palette().highlight().color(), Qt.ForegroundRole) + tooltip = "Active profile (not in quick select)" + self.setItemData(idx, tooltip, Qt.ToolTipRole) + self.setCurrentIndex(idx) + elif profile not in quick_set: + self.setItemData(idx, "Not in quick select", Qt.ToolTipRole) # Restore selection if possible index = self.findText(current_text) @@ -44,6 +66,14 @@ def refresh_profiles(self): self.setCurrentIndex(index) self.blockSignals(False) + if active_profile and self.currentText() != active_profile: + idx = self.findText(active_profile) + if idx >= 0: + self.setCurrentIndex(idx) + if active_profile and active_profile not in quick_set: + self.setToolTip("Active profile is not in quick select") + else: + self.setToolTip("") def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: @@ -56,17 +86,6 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: Returns: ToolbarBundle: The workspace toolbar bundle. """ - # Lock icon action - components.add_safe( - "lock", - MaterialIconAction( - icon_name="lock_open_right", - tooltip="Lock Workspace", - checkable=True, - parent=components.toolbar, - ), - ) - # Workspace combo combo = ProfileComboBox(parent=components.toolbar) components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) @@ -83,31 +102,31 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: ) # Delete workspace icon components.add_safe( - "refresh_workspace", + "reset_default_workspace", MaterialIconAction( - icon_name="refresh", + icon_name="undo", tooltip="Refresh Current Workspace", checkable=False, parent=components.toolbar, ), ) - # Delete workspace icon + # Workspace Manager icon components.add_safe( - "delete_workspace", + "manage_workspaces", MaterialIconAction( - icon_name="delete", - tooltip="Delete Current Workspace", - checkable=False, + icon_name="manage_accounts", + tooltip="Manage", + checkable=True, parent=components.toolbar, + label_text="Manage", ), ) bundle = ToolbarBundle("workspace", components) - bundle.add_action("lock") bundle.add_action("workspace_combo") bundle.add_action("save_workspace") - bundle.add_action("refresh_workspace") - bundle.add_action("delete_workspace") + bundle.add_action("reset_default_workspace") + bundle.add_action("manage_workspaces") return bundle @@ -128,56 +147,40 @@ def __init__(self, components: ToolbarComponents, target_widget=None): def connect(self): self._connected = True # Connect the action to the target widget's method - self.components.get_action("lock").action.toggled.connect(self._lock_workspace) self.components.get_action("save_workspace").action.triggered.connect( self.target_widget.save_profile ) self.components.get_action("workspace_combo").widget.currentTextChanged.connect( self.target_widget.load_profile ) - self.components.get_action("refresh_workspace").action.triggered.connect( - self._refresh_workspace + self.components.get_action("reset_default_workspace").action.triggered.connect( + self._reset_workspace_to_default ) - self.components.get_action("delete_workspace").action.triggered.connect( - self.target_widget.delete_profile + self.components.get_action("manage_workspaces").action.triggered.connect( + self.target_widget.show_workspace_manager ) def disconnect(self): if not self._connected: return # Disconnect the action from the target widget's method - self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace) self.components.get_action("save_workspace").action.triggered.disconnect( self.target_widget.save_profile ) self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( self.target_widget.load_profile ) - self.components.get_action("refresh_workspace").action.triggered.disconnect( - self._refresh_workspace + self.components.get_action("reset_default_workspace").action.triggered.disconnect( + self._reset_workspace_to_default ) - self.components.get_action("delete_workspace").action.triggered.disconnect( - self.target_widget.delete_profile + self.components.get_action("manage_workspaces").action.triggered.disconnect( + self.target_widget.show_workspace_manager ) self._connected = False - @SafeSlot(bool) - def _lock_workspace(self, value: bool): - """ - Switches the workspace lock state and change the icon accordingly. - """ - setattr(self.target_widget, "lock_workspace", value) - self.components.get_action("lock").action.setChecked(value) - icon = material_icon( - "lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False - ) - self.components.get_action("lock").action.setIcon(icon) - @SafeSlot() - def _refresh_workspace(self): + def _reset_workspace_to_default(self): """ Refreshes the current workspace. """ - combo = self.components.get_action("workspace_combo").widget - current_workspace = combo.currentText() - self.target_widget.load_profile(current_workspace) + self.target_widget.restore_user_profile_from_default() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d6563a33e..6dfe76e59 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -1,28 +1,45 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +import base64 import os -import tempfile from unittest import mock from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings +from qtpy.QtCore import QSettings, Qt +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QDialog, QMessageBox +import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( AdvancedDockArea, DockSettingsDialog, SaveProfileDialog, ) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - is_profile_readonly, + default_profile_path, + get_profile_info, + is_profile_read_only, + is_quick_select, list_profiles, - open_settings, - profile_path, + load_default_profile_screenshot, + load_user_profile_screenshot, + open_default_settings, + open_user_settings, + plugin_profiles_dir, read_manifest, - set_profile_readonly, + restore_user_from_default, + set_quick_select, + user_profile_path, write_manifest, ) +from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( + PreviewPanel, + RestoreProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( + WorkSpaceManager, +) from .client_mocks import mocked_client @@ -36,12 +53,96 @@ def advanced_dock_area(qtbot, mocked_client): yield widget +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + """Ensure each test writes profiles into a unique temporary directory.""" + root = tmp_path / "profiles_root" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + yield + + @pytest.fixture def temp_profile_dir(): - """Create a temporary directory for profile testing.""" - with tempfile.TemporaryDirectory() as temp_dir: - with patch.dict(os.environ, {"BECWIDGETS_PROFILE_DIR": temp_dir}): - yield temp_dir + """Return the current temporary profile directory.""" + return os.environ["BECWIDGETS_PROFILE_DIR"] + + +@pytest.fixture +def module_profile_factory(monkeypatch, tmp_path): + """Provide a helper to create synthetic module-level (read-only) profiles.""" + module_dir = tmp_path / "module_profiles" + module_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(module_dir)) + monkeypatch.setattr(profile_utils, "plugin_profiles_dir", lambda: None) + + def _create(name="readonly_profile", content="[profile]\n"): + path = module_dir / f"{name}.ini" + path.write_text(content) + return name + + return _create + + +@pytest.fixture +def workspace_manager_target(): + class _Signal: + def __init__(self): + self._slot = None + + def connect(self, slot): + self._slot = slot + + def emit(self, value): + if self._slot: + self._slot(value) + + class _Combo: + def __init__(self): + self.current_text = "" + + def setCurrentText(self, text): + self.current_text = text + + class _Action: + def __init__(self, widget): + self.widget = widget + + class _Components: + def __init__(self, combo): + self._combo = combo + + def get_action(self, name): + return _Action(self._combo) + + class _Toolbar: + def __init__(self, combo): + self.components = _Components(combo) + + class _Target: + def __init__(self): + self.profile_changed = _Signal() + self._combo = _Combo() + self.toolbar = _Toolbar(self._combo) + self._current_profile_name = None + self.load_profile_calls = [] + self.save_called = False + self.refresh_calls = 0 + + def load_profile(self, name): + self.load_profile_calls.append(name) + self._current_profile_name = name + + def save_profile(self): + self.save_called = True + + def _refresh_workspace_list(self): + self.refresh_calls += 1 + + def _factory(): + return _Target() + + return _factory class TestAdvancedDockAreaInit: @@ -81,7 +182,7 @@ def test_new_widget_string(self, advanced_dock_area, qtbot): initial_count = len(advanced_dock_area.dock_list()) # Create a widget by string name - widget = advanced_dock_area.new("Waveform") + widget = advanced_dock_area.new("DarkModeButton") # Wait for the dock to be created (since it's async) qtbot.wait(200) @@ -430,7 +531,6 @@ def test_save_profile_dialog_init(self, qtbot): assert dialog.windowTitle() == "Save Workspace Profile" assert dialog.isModal() assert dialog.name_edit.text() == "test_profile" - assert hasattr(dialog, "readonly_checkbox") def test_save_profile_dialog_get_values(self, qtbot): """Test getting values from SaveProfileDialog.""" @@ -438,10 +538,10 @@ def test_save_profile_dialog_get_values(self, qtbot): qtbot.addWidget(dialog) dialog.name_edit.setText("my_profile") - dialog.readonly_checkbox.setChecked(True) + dialog.quick_select_checkbox.setChecked(True) assert dialog.get_profile_name() == "my_profile" - assert dialog.is_readonly() is True + assert dialog.is_quick_select() is True def test_save_button_enabled_state(self, qtbot): """Test save button is enabled/disabled based on name input.""" @@ -459,56 +559,568 @@ def test_save_button_enabled_state(self, qtbot): dialog.name_edit.setText("") assert not dialog.save_btn.isEnabled() + def test_accept_blocks_empty_name(self, qtbot): + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + dialog.name_edit.clear() + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.overwrite_existing is False + + def test_accept_readonly_suggests_unique_name(self, qtbot, monkeypatch): + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + + dialog = SaveProfileDialog( + None, + name_exists=lambda name: name == "readonly_custom", + profile_origin=lambda name: "module" if name == "readonly" else "unknown", + origin_label=lambda name: "ModuleDefaults", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("readonly") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("readonly_custom") + assert dialog.overwrite_checkbox.isChecked() is False + assert info_calls, "Expected informational prompt for read-only profile" + + def test_accept_existing_profile_confirm_yes(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: name == "profile_a", + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Accepted + assert dialog.overwrite_existing is True + + def test_accept_existing_profile_confirm_no(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: False, + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("profile_a_custom") + assert dialog.overwrite_existing is False + assert dialog.overwrite_checkbox.isChecked() is False + + def test_overwrite_toggle_sets_and_restores_name(self, qtbot): + dialog = SaveProfileDialog( + None, current_name="custom_name", current_profile_name="existing_profile" + ) + qtbot.addWidget(dialog) + + dialog.overwrite_checkbox.setChecked(True) + assert dialog.name_edit.text() == "existing_profile" + dialog.name_edit.setText("existing_profile") + dialog.overwrite_checkbox.setChecked(False) + assert dialog.name_edit.text() == "custom_name" + + +class TestPreviewPanel: + """Test preview panel scaling behavior.""" + + def test_preview_panel_without_pixmap(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + assert "No preview available" in panel.image_label.text() + + def test_preview_panel_with_pixmap(self, qtbot): + pixmap = QPixmap(40, 20) + pixmap.fill(Qt.red) + panel = PreviewPanel("Current", pixmap) + qtbot.addWidget(panel) + assert panel.image_label.pixmap() is not None + + def test_preview_panel_set_pixmap_resets_placeholder(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + pixmap = QPixmap(30, 30) + pixmap.fill(Qt.blue) + panel.setPixmap(pixmap) + assert panel.image_label.pixmap() is not None + panel.setPixmap(None) + assert panel.image_label.pixmap() is None or panel.image_label.pixmap().isNull() + assert "No preview available" in panel.image_label.text() + + +class TestRestoreProfileDialog: + """Test restore dialog confirmation flow.""" + + def test_confirm_accepts(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Accepted) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is True + + def test_confirm_rejects(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Rejected) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is False + + +class TestProfileInfoAndScreenshots: + """Tests for profile utilities metadata and screenshot helpers.""" + + PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAAFUlEQVQYlWP8//8/A27AhEduBEsDAKXjAxHmByO3AAAAAElFTkSuQmCC" + ) + + def _write_manifest(self, settings, count=2): + settings.beginWriteArray(profile_utils.SETTINGS_KEYS["manifest"], count) + for i in range(count): + settings.setArrayIndex(i) + settings.setValue("object_name", f"widget_{i}") + settings.setValue("widget_class", "Dummy") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + def test_get_profile_info_user_origin(self, temp_profile_dir): + name = "info_user" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") + settings.setValue("profile/author", "Custom") + set_quick_select(name, True) + self._write_manifest(settings, count=3) + + info = get_profile_info(name) + + assert info.name == name + assert info.origin == "settings" + assert info.is_read_only is False + assert info.is_quick_select is True + assert info.widget_count == 3 + assert info.author == "User" + assert info.user_path.endswith(f"{name}.ini") + assert info.size_kb >= 0 + + def test_get_profile_info_default_only(self, temp_profile_dir): + name = "info_default" + settings = open_default_settings(name) + self._write_manifest(settings, count=1) + + user_path = user_profile_path(name) + if os.path.exists(user_path): + os.remove(user_path) + + info = get_profile_info(name) + + assert info.origin == "settings" + assert info.user_path.endswith(f"{name}.ini") + assert info.widget_count == 1 + + def test_get_profile_info_module_readonly(self, module_profile_factory): + name = module_profile_factory("info_readonly") + info = get_profile_info(name) + assert info.origin == "module" + assert info.is_read_only is True + assert info.author == "BEC Widgets" + + def test_get_profile_info_unknown_profile(self): + name = "nonexistent_profile" + if os.path.exists(user_profile_path(name)): + os.remove(user_profile_path(name)) + if os.path.exists(default_profile_path(name)): + os.remove(default_profile_path(name)) + + info = get_profile_info(name) + + assert info.origin == "unknown" + assert info.is_read_only is False + assert info.widget_count == 0 + + def test_load_user_profile_screenshot(self, temp_profile_dir): + name = "user_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_user_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_default_profile_screenshot(self, temp_profile_dir): + name = "default_screenshot" + settings = open_default_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_default_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): + name = "invalid_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is None + + def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): + name = "bytes_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is not None and not pix.isNull() + + +class TestWorkSpaceManager: + """Test workspace manager interactions.""" + + @staticmethod + def _create_profiles(names): + for name in names: + settings = open_user_settings(name) + settings.setValue("meta", "value") + settings.sync() + + def test_render_table_populates_rows(self, qtbot): + profile_names = ["profile_a", "profile_b"] + self._create_profiles(profile_names) + + manager = WorkSpaceManager(target_widget=None) + qtbot.addWidget(manager) + + assert manager.profile_table.rowCount() >= len(profile_names) + + def test_switch_profile_updates_target(self, qtbot, workspace_manager_target): + name = "profile_switch" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.switch_profile(name) + + assert target.load_profile_calls == [name] + assert target._combo.current_text == name + assert manager._current_selected_profile() == name + + def test_toggle_quick_select_updates_flag(self, qtbot, workspace_manager_target): + name = "profile_toggle" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + initial = is_quick_select(name) + manager.toggle_quick_select(name) + + assert is_quick_select(name) is (not initial) + assert target.refresh_calls >= 1 + + def test_save_current_as_profile_with_target(self, qtbot, workspace_manager_target): + name = "profile_save" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.save_current_as_profile() + + assert target.save_called is True + assert manager._current_selected_profile() == name + + def test_delete_profile_removes_files(self, qtbot, workspace_manager_target, monkeypatch): + name = "profile_delete" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) + + manager.delete_profile(name) + + assert not os.path.exists(user_profile_path(name)) + assert target.refresh_calls >= 1 + + def test_delete_readonly_profile_shows_message( + self, qtbot, workspace_manager_target, module_profile_factory, monkeypatch + ): + readonly = module_profile_factory("readonly_delete") + list_profiles() + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + manager = WorkSpaceManager(target_widget=workspace_manager_target()) + qtbot.addWidget(manager) + + manager.delete_profile(readonly) + + assert info_calls, "Expected informational prompt for read-only profile" + + +class TestAdvancedDockAreaRestoreAndDialogs: + """Additional coverage for restore flows and workspace dialogs.""" + + def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_true" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + pix = QPixmap(8, 8) + pix.fill(Qt.red) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: True, + ) + + with ( + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore, + patch.object(advanced_dock_area, "delete_all") as mock_delete_all, + patch.object(advanced_dock_area, "load_profile") as mock_load_profile, + ): + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_called_once_with(profile_name) + mock_delete_all.assert_called_once() + mock_load_profile.assert_called_once_with(profile_name) + + def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_false" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: False, + ) + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore: + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_not_called() + + def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): + advanced_dock_area._current_profile_name = None + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm" + ) as mock_confirm: + advanced_dock_area.restore_user_profile_from_default() + mock_confirm.assert_not_called() + + def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): + profile_name = "refresh_profile" + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles = MagicMock() + + advanced_dock_area._refresh_workspace_list() + + combo.refresh_profiles.assert_called_once_with(profile_name) + + def test_refresh_workspace_list_fallback(self, advanced_dock_area): + class ComboStub: + def __init__(self): + self.items = [] + self.tooltip = "" + self.block_calls = [] + self.cleared = False + self.current_index = -1 + + def blockSignals(self, value): + self.block_calls.append(value) + + def clear(self): + self.items.clear() + self.cleared = True + + def addItems(self, items): + self.items.extend(items) + + def findText(self, text): + try: + return self.items.index(text) + except ValueError: + return -1 + + def setCurrentIndex(self, idx): + self.current_index = idx + + def setToolTip(self, text): + self.tooltip = text + + active = "active_profile" + quick = "quick_profile" + open_user_settings(active).sync() + open_user_settings(quick).sync() + set_quick_select(quick, True) + + combo_stub = ComboStub() + + class StubAction: + def __init__(self, widget): + self.widget = widget + + with patch.object( + advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub) + ): + advanced_dock_area._current_profile_name = active + advanced_dock_area._refresh_workspace_list() + + assert combo_stub.block_calls == [True, False] + assert combo_stub.items[0] == active + assert combo_stub.tooltip == "Active profile is not in quick select" + + def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + assert not action.isChecked() + + advanced_dock_area._current_profile_name = "manager_profile" + open_user_settings("manager_profile").sync() + + advanced_dock_area.show_workspace_manager() + + assert advanced_dock_area.manage_dialog is not None + assert advanced_dock_area.manage_dialog.isVisible() + assert action.isChecked() + assert isinstance(advanced_dock_area.manage_widget, WorkSpaceManager) + + advanced_dock_area.manage_dialog.close() + qtbot.waitUntil(lambda: advanced_dock_area.manage_dialog is None) + assert not action.isChecked() + + def test_manage_dialog_closed(self, advanced_dock_area): + widget_mock = MagicMock() + dialog_mock = MagicMock() + advanced_dock_area.manage_widget = widget_mock + advanced_dock_area.manage_dialog = dialog_mock + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + action.setChecked(True) + + advanced_dock_area._manage_dialog_closed() + + widget_mock.close.assert_called_once() + widget_mock.deleteLater.assert_called_once() + dialog_mock.deleteLater.assert_called_once() + assert advanced_dock_area.manage_dialog is None + assert not action.isChecked() + class TestProfileManagement: """Test profile management functionality.""" def test_profile_path(self, temp_profile_dir): """Test profile path generation.""" - path = profile_path("test_profile") - expected = os.path.join(temp_profile_dir, "test_profile.ini") + path = user_profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") assert path == expected + default_path = default_profile_path("test_profile") + expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") + assert default_path == expected_default + def test_open_settings(self, temp_profile_dir): """Test opening settings for a profile.""" - settings = open_settings("test_profile") + settings = open_user_settings("test_profile") assert isinstance(settings, QSettings) def test_list_profiles_empty(self, temp_profile_dir): """Test listing profiles when directory is empty.""" + try: + module_defaults = { + os.path.splitext(f)[0] + for f in os.listdir(profile_utils.module_profiles_dir()) + if f.endswith(".ini") + } + except FileNotFoundError: + module_defaults = set() profiles = list_profiles() - assert profiles == [] + assert module_defaults.issubset(set(profiles)) def test_list_profiles_with_files(self, temp_profile_dir): """Test listing profiles with existing files.""" # Create some test profile files profile_names = ["profile1", "profile2", "profile3"] for name in profile_names: - settings = open_settings(name) + settings = open_user_settings(name) settings.setValue("test", "value") settings.sync() profiles = list_profiles() - assert sorted(profiles) == sorted(profile_names) + for name in profile_names: + assert name in profiles - def test_readonly_profile_operations(self, temp_profile_dir): + def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): """Test read-only profile functionality.""" - profile_name = "readonly_profile" + profile_name = "user_profile" # Initially should not be read-only - assert not is_profile_readonly(profile_name) + assert not is_profile_read_only(profile_name) - # Set as read-only - set_profile_readonly(profile_name, True) - assert is_profile_readonly(profile_name) + # Create a user profile and ensure it's writable + settings = open_user_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + assert not is_profile_read_only(profile_name) - # Unset read-only - set_profile_readonly(profile_name, False) - assert not is_profile_readonly(profile_name) + # Verify a bundled module profile is detected as read-only + readonly_name = module_profile_factory("module_default") + assert is_profile_read_only(readonly_name) def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): """Test writing and reading dock manifest.""" - settings = open_settings("test_manifest") + settings = open_user_settings("test_manifest") # Create real docks advanced_dock_area.new("DarkModeButton") @@ -535,44 +1147,65 @@ def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtb assert "floatable" in item assert "movable" in item + def test_restore_preserves_quick_select(self, temp_profile_dir): + """Ensure restoring keeps the quick select flag when it was enabled.""" + profile_name = "restorable_profile" + default_settings = open_default_settings(profile_name) + default_settings.setValue("test", "default") + default_settings.sync() + + user_settings = open_user_settings(profile_name) + user_settings.setValue("test", "user") + user_settings.sync() + + set_quick_select(profile_name, True) + assert is_quick_select(profile_name) + + restore_user_from_default(profile_name) + + assert is_quick_select(profile_name) + class TestWorkspaceProfileOperations: """Test workspace profile save/load/delete operations.""" - def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): + def test_save_profile_readonly_conflict( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): """Test saving profile when read-only profile exists.""" - profile_name = "readonly_profile" + profile_name = module_profile_factory("readonly_profile") + new_profile = f"{profile_name}_custom" + target_path = user_profile_path(new_profile) + if os.path.exists(target_path): + os.remove(target_path) - # Create a read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) - settings.setValue("test", "value") - settings.sync() + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False - with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog" - ) as mock_dialog_class: - mock_dialog = MagicMock() - mock_dialog.exec.return_value = QDialog.Accepted - mock_dialog.get_profile_name.return_value = profile_name - mock_dialog.is_readonly.return_value = False - mock_dialog_class.return_value = mock_dialog + def exec(self): + return QDialog.Accepted - with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" - ) as mock_warning: - mock_warning.return_value = QMessageBox.No + def get_profile_name(self): + return new_profile - advanced_dock_area.save_profile() + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile(profile_name) - mock_warning.assert_called_once() + assert os.path.exists(target_path) def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): """Test loading profile with widget manifest.""" profile_name = "test_load_profile" # Create a profile with manifest - settings = open_settings(profile_name) + settings = open_user_settings(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -583,8 +1216,6 @@ def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, settings.endArray() settings.sync() - initial_count = len(advanced_dock_area.widget_map()) - # Load profile advanced_dock_area.load_profile(profile_name) @@ -595,15 +1226,96 @@ def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, widget_map = advanced_dock_area.widget_map() assert "test_widget" in widget_map - def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): - """Test deleting read-only profile shows warning.""" - profile_name = "readonly_profile" + def test_save_as_skips_autosave_source_profile( + self, advanced_dock_area, temp_profile_dir, qtbot + ): + """Saving a new profile avoids overwriting the source profile during the switch.""" + source_profile = "autosave_source" + new_profile = "autosave_new" + + settings = open_user_settings(source_profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "source_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(source_profile) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile() + + qtbot.wait(500) + source_manifest = read_manifest(open_user_settings(source_profile)) + new_manifest = read_manifest(open_user_settings(new_profile)) + + assert len(source_manifest) == 1 + assert len(new_manifest) == 2 + + def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profile_dir, qtbot): + """Regular profile switches should persist the outgoing layout.""" + profile_a = "autosave_keep" + profile_b = "autosave_target" + + for profile in (profile_a, profile_b): + settings = open_user_settings(profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", f"{profile}_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(profile_a) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + advanced_dock_area.load_profile(profile_b) + qtbot.wait(500) + + manifest_a = read_manifest(open_user_settings(profile_a)) + assert len(manifest_a) == 2 - # Create read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) + def test_delete_profile_readonly( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): + """Test deleting bundled profile removes only the writable copy.""" + profile_name = module_profile_factory("readonly_profile") + list_profiles() # ensure default and user copies are materialized + settings = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + default_path = default_profile_path(profile_name) + assert os.path.exists(user_path) + assert os.path.exists(default_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -611,22 +1323,27 @@ def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): mock_get_action.return_value.widget = mock_combo with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" - ) as mock_warning: + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + advanced_dock_area.delete_profile() - mock_warning.assert_called_once() - # Profile should still exist - assert os.path.exists(profile_path(profile_name)) + mock_question.assert_called_once() + # User copy should be removed, default remains + assert not os.path.exists(user_path) + assert os.path.exists(default_path) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" profile_name = "deletable_profile" # Create regular profile - settings = open_settings(profile_name) + settings = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + assert os.path.exists(user_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -644,13 +1361,13 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): mock_question.assert_called_once() mock_refresh.assert_called_once() # Profile should be deleted - assert not os.path.exists(profile_path(profile_name)) + assert not os.path.exists(user_path) def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles for name in ["profile1", "profile2"]: - settings = open_settings(name) + settings = open_user_settings(name) settings.setValue("test", "value") settings.sync() From f7061baf7b6740bf300f9f1824a7a0e1739c9d28 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 7 Nov 2025 14:05:59 +0100 Subject: [PATCH 031/155] fix(main_window): removed general forced cleanup --- .../widgets/containers/main_window/main_window.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index c1a86eaa6..6dc5fa2c6 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -404,21 +404,6 @@ def event(self, event): return super().event(event) def cleanup(self): - central_widget = self.centralWidget() - if central_widget is not None: - central_widget.close() - central_widget.deleteLater() - if not isinstance(central_widget, BECWidget): - # if the central widget is not a BECWidget, we need to call the cleanup method - # of all widgets whose parent is the current BECMainWindow - children = self.findChildren(BECWidget) - for child in children: - ancestor = WidgetHierarchy._get_becwidget_ancestor(child) - if ancestor is self: - if shiboken6.isValid(child): - child.close() - child.deleteLater() - # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): self._client_info_expire_timer.stop() From 2dfae4d38feb6678c2e98cfbe2dafe418e95239d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 4 Nov 2025 16:59:23 +0100 Subject: [PATCH 032/155] feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants --- .../advanced_dock_area/advanced_dock_area.py | 687 ++++----- .../advanced_dock_area/basic_dock_area.py | 1368 +++++++++++++++++ .../advanced_dock_area/profile_utils.py | 679 ++++++-- .../advanced_dock_area/settings/dialogs.py | 21 +- .../settings/workspace_manager.py | 34 +- .../toolbar_components/workspace_actions.py | 66 +- .../containers/main_window/main_window.py | 2 - tests/unit_tests/test_advanced_dock_area.py | 522 ++++++- 8 files changed, 2761 insertions(+), 618 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 489d24512..a7a062ba1 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -1,11 +1,11 @@ from __future__ import annotations import os -from typing import Literal, cast +from typing import Callable, Literal, Mapping, Sequence import PySide6QtAds as QtAds from bec_lib import bec_logger -from PySide6QtAds import CDockManager, CDockWidget +from PySide6QtAds import CDockWidget from qtpy.QtCore import QTimer, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( @@ -17,13 +17,11 @@ QVBoxLayout, QWidget, ) -from shiboken6 import isValid from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme -from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, @@ -32,11 +30,15 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_state_manager import WidgetStateManager +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( SETTINGS_KEYS, - default_profile_path, + default_profile_candidates, + delete_profile_files, get_last_profile, + is_profile_read_only, is_quick_select, + list_quick_profiles, load_default_profile_screenshot, load_user_profile_screenshot, now_iso_utc, @@ -46,9 +48,10 @@ profile_origin_display, read_manifest, restore_user_from_default, + sanitize_namespace, set_last_profile, set_quick_select, - user_profile_path, + user_profile_candidates, write_manifest, ) from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( @@ -80,30 +83,26 @@ logger = bec_logger.logger +_PROFILE_NAMESPACE_UNSET = object() -class DockSettingsDialog(QDialog): +PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} - def __init__(self, parent: QWidget, target: QWidget): - super().__init__(parent) - self.setWindowTitle("Dock Settings") - self.setModal(True) - layout = QVBoxLayout(self) - # Property editor - self.prop_editor = PropertyEditor(target, self, show_only_bec=True) - layout.addWidget(self.prop_editor) - - -class AdvancedDockArea(BECWidget, QWidget): +class AdvancedDockArea(DockAreaWidget): RPC = True PLUGIN = False USER_ACCESS = [ "new", + "dock_map", + "dock_list", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", "mode", "mode.setter", ] @@ -115,38 +114,34 @@ class AdvancedDockArea(BECWidget, QWidget): def __init__( self, parent=None, - mode: str = "developer", + mode: Literal["plot", "device", "utils", "user", "creator"] = "creator", default_add_direction: Literal["left", "right", "top", "bottom"] = "right", - *args, + profile_namespace: str | None = None, + auto_profile_namespace: bool = True, + auto_save_upon_exit: bool = True, + enable_profile_management: bool = True, + restore_initial_profile: bool = True, **kwargs, ): - super().__init__(parent=parent, *args, **kwargs) - - # Title (as a top-level QWidget it can have a window title) - self.setWindowTitle("Advanced Dock Area") - - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - - # Init Dock Manager - self.dock_manager = CDockManager(self) - self.dock_manager.setStyleSheet("") - - # Dock manager helper variables - self._locked = False # Lock state of the workspace + self._profile_namespace_hint = profile_namespace + self._profile_namespace_auto = auto_profile_namespace + self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET + self._auto_save_upon_exit = auto_save_upon_exit + self._profile_management_enabled = enable_profile_management + self._restore_initial_profile = restore_initial_profile + super().__init__( + parent, + default_add_direction=default_add_direction, + title="Advanced Dock Area", + **kwargs, + ) # Initialize mode property first (before toolbar setup) - self._mode = "developer" - self._default_add_direction = ( - default_add_direction - if default_add_direction in ("left", "right", "top", "bottom") - else "right" - ) + self._mode = mode # Toolbar self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self.dark_mode_button.setVisible(enable_profile_management) self._setup_toolbar() self._hook_toolbar() @@ -154,14 +149,14 @@ def __init__( self.save_dialog = None self.manage_dialog = None - # Place toolbar and dock manager into layout - self._root_layout.addWidget(self.toolbar) - self._root_layout.addWidget(self.dock_manager, 1) + # Place toolbar above the dock manager provided by the base class + self._root_layout.insertWidget(0, self.toolbar) # Populate and hook the workspace combo self._refresh_workspace_list() self._current_profile_name = None self._pending_autosave_skip: tuple[str, str] | None = None + self._exit_snapshot_written = False # State manager self.state_manager = WidgetStateManager(self) @@ -177,79 +172,96 @@ def __init__( # Apply the requested mode after everything is set up self.mode = mode - QTimer.singleShot( - 0, self._fetch_initial_profile - ) # To allow full init before loading profile and prevent segfault on exit + if self._restore_initial_profile: + self._fetch_initial_profile() def _fetch_initial_profile(self): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget - last = get_last_profile() - if last and ( - os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last)) - ): - init_profile = last + namespace = self.profile_namespace + last = get_last_profile(namespace) + if last: + user_exists = any( + os.path.exists(path) for path in user_profile_candidates(last, namespace) + ) + default_exists = any( + os.path.exists(path) for path in default_profile_candidates(last, namespace) + ) + init_profile = last if (user_exists or default_exists) else None else: init_profile = combo.currentText() + if not init_profile: + general_exists = any( + os.path.exists(path) for path in user_profile_candidates("general", namespace) + ) or any( + os.path.exists(path) for path in default_profile_candidates("general", namespace) + ) + if general_exists: + init_profile = "general" if init_profile: - self.load_profile(init_profile) - combo.setCurrentText(init_profile) + # Defer initial load to the event loop so child widgets exist before state restore. + QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) - def _make_dock( + def _load_initial_profile(self, name: str) -> None: + """Load the initial profile after construction when the event loop is running.""" + self.load_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.blockSignals(True) + combo.setCurrentText(name) + combo.blockSignals(False) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + prefs = getattr(dock, "_dock_preferences", {}) or {} + if prefs.get("show_settings_action") is None: + prefs = dict(prefs) + prefs["show_settings_action"] = True + dock._dock_preferences = prefs + super()._customize_dock(dock, widget) + + @SafeSlot(popup_error=True) + def new( self, - widget: QWidget, + widget: QWidget | str, *, - closable: bool, - floatable: bool, + closable: bool = True, + floatable: bool = True, movable: bool = True, - area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, start_floating: bool = False, - ) -> CDockWidget: - dock = CDockWidget(widget.objectName()) - dock.setWidget(widget) - dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) - dock.setFeature(CDockWidget.CustomCloseHandling, True) - dock.setFeature(CDockWidget.DockWidgetClosable, closable) - dock.setFeature(CDockWidget.DockWidgetFloatable, floatable) - dock.setFeature(CDockWidget.DockWidgetMovable, movable) - - self._install_dock_settings_action(dock, widget) - - def on_dock_close(): - widget.close() - dock.closeDockWidget() - dock.deleteDockWidget() - - def on_widget_destroyed(): - if not isValid(dock): - return - dock.closeDockWidget() - dock.deleteDockWidget() - - dock.closeRequested.connect(on_dock_close) - if hasattr(widget, "widget_removed"): - widget.widget_removed.connect(on_widget_destroyed) - - dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) - self.dock_manager.addDockWidget(area, dock) - if start_floating: - dock.setFloating() - return dock - - def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: - action = MaterialIconAction( - icon_name="settings", tooltip="Dock settings", filled=True, parent=self - ).action - action.setToolTip("Dock settings") - action.setObjectName("dockSettingsAction") - action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) - dock.setTitleBarActions([action]) - dock.setting_action = action - - def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: - dlg = DockSettingsDialog(self, widget) - dlg.resize(600, 600) - dlg.exec() + where: Literal["left", "right", "top", "bottom"] | None = None, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | QWidget | str | None = None, + relative_to: CDockWidget | QWidget | str | None = None, + return_dock: bool = False, + show_title_bar: bool | None = None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, + show_settings_action: bool | None = None, + promote_central: bool = False, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Override the base helper so dock settings are available by default. + + The flag remains user-configurable (pass ``False`` to hide the action). + """ + if show_settings_action is None: + show_settings_action = True + return super().new( + widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + return_dock=return_dock, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + **widget_kwargs, + ) def _apply_dock_lock(self, locked: bool) -> None: if locked: @@ -257,28 +269,6 @@ def _apply_dock_lock(self, locked: bool) -> None: else: self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures) - def _delete_dock(self, dock: CDockWidget) -> None: - w = dock.widget() - if w and isValid(w): - w.close() - w.deleteLater() - if isValid(dock): - dock.closeDockWidget() - dock.deleteDockWidget() - - def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: - """Return ADS DockWidgetArea from a human-friendly direction string. - If *where* is None, fall back to instance default. - """ - d = (where or getattr(self, "_default_add_direction", "right") or "right").lower() - mapping = { - "left": QtAds.DockWidgetArea.LeftDockWidgetArea, - "right": QtAds.DockWidgetArea.RightDockWidgetArea, - "top": QtAds.DockWidgetArea.TopDockWidgetArea, - "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, - } - return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea) - ################################################################################ # Toolbar Setup ################################################################################ @@ -353,7 +343,12 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) self.toolbar.components.add_safe( flat_action_id, MaterialIconAction( - icon_name=icon_name, tooltip=tooltip, filled=True, parent=self + icon_name=icon_name, + tooltip=tooltip, + filled=True, + parent=self, + label_text=widget_type, + text_position="under", ), ) bundle.add_action(flat_action_id) @@ -372,7 +367,9 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) spacer_bundle.add_action("spacer") self.toolbar.add_bundle(spacer_bundle) - self.toolbar.add_bundle(workspace_bundle(self.toolbar.components)) + self.toolbar.add_bundle( + workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled) + ) self.toolbar.connect_bundle( "workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self) ) @@ -384,20 +381,22 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self ), ) + self.toolbar.components.get_action("attach_all").action.setVisible( + self._profile_management_enabled + ) self.toolbar.components.add_safe( "screenshot", MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self), ) - self.toolbar.components.add_safe( - "dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self) + self.toolbar.components.get_action("screenshot").action.setVisible( + self._profile_management_enabled + ) + dark_mode_action = WidgetAction( + widget=self.dark_mode_button, adjust_size=False, parent=self ) - # Developer mode toggle (moved from menu into toolbar) #TODO temporary disable - # self.toolbar.components.add_safe( - # "developer_mode", - # MaterialIconAction( - # icon_name="code", tooltip="Developer Mode", checkable=True, parent=self - # ), - # ) + dark_mode_action.widget.setVisible(self._profile_management_enabled) + self.toolbar.components.add_safe("dark_mode", dark_mode_action) + bda = ToolbarBundle("dock_actions", self.toolbar.components) bda.add_action("attach_all") bda.add_action("screenshot") @@ -405,17 +404,7 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) # bda.add_action("developer_mode") #TODO temporary disable self.toolbar.add_bundle(bda) - # Default bundle configuration (show menus by default) - self.toolbar.show_bundles( - [ - "menu_plots", - "menu_devices", - "menu_utils", - "spacer_bundle", - "workspace", - "dock_actions", - ] - ) + self._apply_toolbar_layout() # Store mappings on self for use in _hook_toolbar self._ACTION_MAPPINGS = { @@ -425,10 +414,11 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) } def _hook_toolbar(self): - def _connect_menu(menu_key: str): menu = self.toolbar.components.get_action(menu_key) mapping = self._ACTION_MAPPINGS[menu_key] + + # first two items not needed for this part for key, (_, _, widget_type) in mapping.items(): act = menu.actions[key].action if widget_type == "LogPanel": @@ -443,6 +433,7 @@ def _connect_menu(menu_key: str): widget=t, closable=True, startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", + show_settings_action=True, ) ) else: @@ -452,197 +443,33 @@ def _connect_menu(menu_key: str): _connect_menu("menu_devices") _connect_menu("menu_utils") - # Connect flat toolbar actions - def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]): + def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]): for action_id, (_, _, widget_type) in mapping.items(): flat_action_id = f"flat_{action_id}" flat_action = self.toolbar.components.get_action(flat_action_id).action if widget_type == "LogPanel": flat_action.setEnabled(False) # keep disabled per issue #644 else: - flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + flat_action.triggered.connect(lambda _, t=widget_type: self.new(t)) - _connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"]) - _connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"]) - _connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"]) self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) - # Developer mode toggle #TODO temporary disable - # self.toolbar.components.get_action("developer_mode").action.toggled.connect( - # self._on_developer_mode_toggled - # ) def _set_editable(self, editable: bool) -> None: self.lock_workspace = not editable self._editable = editable - attach_all_action = self.toolbar.components.get_action("attach_all").action - attach_all_action.setVisible(editable) - - # Show full creation menus only when editable; otherwise keep minimal set - if editable: - self.toolbar.show_bundles( - [ - "menu_plots", - "menu_devices", - "menu_utils", - "spacer_bundle", - "workspace", - "dock_actions", - ] - ) - else: - self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) - - # Keep Developer mode UI in sync #TODO temporary disable - # self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + if self._profile_management_enabled: + self.toolbar.components.get_action("attach_all").action.setVisible(editable) def _on_developer_mode_toggled(self, checked: bool) -> None: """Handle developer mode checkbox toggle.""" self._set_editable(checked) - ################################################################################ - # Adding widgets - ################################################################################ - @SafeSlot(popup_error=True) - def new( - self, - widget: BECWidget | str, - closable: bool = True, - floatable: bool = True, - movable: bool = True, - start_floating: bool = False, - where: Literal["left", "right", "top", "bottom"] | None = None, - **kwargs, - ) -> BECWidget: - """ - Create a new widget (or reuse an instance) and add it as a dock. - - Args: - widget: Widget instance or a string widget type (factory-created). - closable: Whether the dock is closable. - floatable: Whether the dock is floatable. - movable: Whether the dock is movable. - start_floating: Start the dock in a floating state. - where: Preferred area to add the dock: "left" | "right" | "top" | "bottom". - If None, uses the instance default passed at construction time. - **kwargs: The keyword arguments for the widget. - Returns: - The widget instance. - """ - target_area = self._area_from_where(where) - - # 1) Instantiate or look up the widget - if isinstance(widget, str): - widget = cast( - BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs) - ) - widget.name_established.connect( - lambda: self._create_dock_with_name( - widget=widget, - closable=closable, - floatable=floatable, - movable=movable, - start_floating=start_floating, - area=target_area, - ) - ) - return widget - - # If a widget instance is passed, dock it immediately - self._create_dock_with_name( - widget=widget, - closable=closable, - floatable=floatable, - movable=movable, - start_floating=start_floating, - area=target_area, - ) - return widget - - def _create_dock_with_name( - self, - widget: BECWidget, - closable: bool = True, - floatable: bool = False, - movable: bool = True, - start_floating: bool = False, - area: QtAds.DockWidgetArea | None = None, - ): - target_area = area or self._area_from_where(None) - self._make_dock( - widget, - closable=closable, - floatable=floatable, - movable=movable, - area=target_area, - start_floating=start_floating, - ) - self.dock_manager.setFocus() - - ################################################################################ - # Dock Management - ################################################################################ - - def dock_map(self) -> dict[str, CDockWidget]: - """ - Return the dock widgets map as dictionary with names as keys and dock widgets as values. - - Returns: - dict: A dictionary mapping widget names to their corresponding dock widgets. - """ - return self.dock_manager.dockWidgetsMap() - - def dock_list(self) -> list[CDockWidget]: - """ - Return the list of dock widgets. - - Returns: - list: A list of all dock widgets in the dock area. - """ - return self.dock_manager.dockWidgets() - - def widget_map(self) -> dict[str, QWidget]: - """ - Return a dictionary mapping widget names to their corresponding BECWidget instances. - - Returns: - dict: A dictionary mapping widget names to BECWidget instances. - """ - return {dock.objectName(): dock.widget() for dock in self.dock_list()} - - def widget_list(self) -> list[QWidget]: - """ - Return a list of all BECWidget instances in the dock area. - - Returns: - list: A list of all BECWidget instances in the dock area. - """ - return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] - - @SafeSlot() - def attach_all(self): - """ - Return all floating docks to the dock area, preserving tab groups within each floating container. - """ - for container in self.dock_manager.floatingWidgets(): - docks = container.dockWidgets() - if not docks: - continue - target = docks[0] - self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) - for d in docks[1:]: - self.dock_manager.addDockWidgetTab( - QtAds.DockWidgetArea.RightDockWidgetArea, d, target - ) - - @SafeSlot() - def delete_all(self): - """Delete all docks and widgets.""" - for dock in list(self.dock_manager.dockWidgets()): - self._delete_dock(dock) - ################################################################################ # Workspace Management ################################################################################ @@ -666,16 +493,49 @@ def lock_workspace(self, value: bool): """ self._locked = value self._apply_dock_lock(value) - self.toolbar.components.get_action("save_workspace").action.setVisible(not value) + if self._profile_management_enabled: + self.toolbar.components.get_action("save_workspace").action.setVisible(not value) for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _resolve_profile_namespace(self) -> str | None: + if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: + return self._profile_namespace_resolved # type: ignore[return-value] + + candidate = self._profile_namespace_hint + if self._profile_namespace_auto: + if not candidate: + obj_name = self.objectName() + candidate = obj_name if obj_name else None + if not candidate: + title = self.windowTitle() + candidate = title if title and title.strip() else None + if not candidate: + mode_name = getattr(self, "_mode", None) or "creator" + candidate = f"{mode_name}_workspace" + if not candidate: + candidate = self.__class__.__name__ + + resolved = sanitize_namespace(candidate) if candidate else None + if not resolved: + resolved = "general" + self._profile_namespace_resolved = resolved # type: ignore[assignment] + return resolved + + @property + def profile_namespace(self) -> str | None: + """Namespace used to scope user/default profile files for this dock area.""" + return self._resolve_profile_namespace() + + def _active_profile_name_or_default(self) -> str: + name = getattr(self, "_current_profile_name", None) + if not name: + name = "general" + self._current_profile_name = name + return name + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: - settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) - settings.setValue(SETTINGS_KEYS["state"], b"") - settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) - self.dock_manager.addPerspective(self.windowTitle()) - self.dock_manager.savePerspectives(settings) + self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) write_manifest(settings, self.dock_list()) if save_preview: @@ -702,11 +562,13 @@ def save_profile(self, name: str | None = None): name (str | None): The name of the profile to save. If None, prompts the user. """ + namespace = self.profile_namespace + def _profile_exists(profile_name: str) -> bool: - return profile_origin(profile_name) != "unknown" + return profile_origin(profile_name, namespace=namespace) != "unknown" initial_name = name or "" - quickselect_default = is_quick_select(name) if name else False + quickselect_default = is_quick_select(name, namespace=namespace) if name else False current_profile = getattr(self, "_current_profile_name", "") or "" dialog = SaveProfileDialog( @@ -714,8 +576,8 @@ def _profile_exists(profile_name: str) -> bool: current_name=initial_name, current_profile_name=current_profile, name_exists=_profile_exists, - profile_origin=profile_origin, - origin_label=profile_origin_display, + profile_origin=lambda n: profile_origin(n, namespace=namespace), + origin_label=lambda n: profile_origin_display(n, namespace=namespace), quick_select_checked=quickselect_default, ) if dialog.exec() != QDialog.Accepted: @@ -723,7 +585,7 @@ def _profile_exists(profile_name: str) -> bool: name = dialog.get_profile_name() quickselect = dialog.is_quick_select() - origin_before_save = profile_origin(name) + origin_before_save = profile_origin(name, namespace=namespace) overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" # Display saving placeholder workspace_combo = self.toolbar.components.get_action("workspace_combo").widget @@ -733,9 +595,11 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.blockSignals(False) # Create or update default copy controlled by overwrite flag - should_write_default = overwrite_default or not os.path.exists(default_profile_path(name)) + should_write_default = overwrite_default or not any( + os.path.exists(path) for path in default_profile_candidates(name, namespace) + ) if should_write_default: - ds = open_default_settings(name) + ds = open_default_settings(name, namespace=namespace) self._write_snapshot_to_settings(ds) if not ds.value(SETTINGS_KEYS["created_at"], ""): ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) @@ -744,7 +608,7 @@ def _profile_exists(profile_name: str) -> bool: ds.setValue(SETTINGS_KEYS["is_quick_select"], False) # Always (over)write the user copy - us = open_user_settings(name) + us = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(us) if not us.value(SETTINGS_KEYS["created_at"], ""): us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) @@ -754,7 +618,7 @@ def _profile_exists(profile_name: str) -> bool: # set quick select if quickselect: - set_quick_select(name, quickselect) + set_quick_select(name, quickselect, namespace=namespace) self._refresh_workspace_list() if current_profile and current_profile != name and not dialog.overwrite_existing: @@ -764,7 +628,7 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.setCurrentText(name) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name) + set_last_profile(name, namespace=namespace) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -782,21 +646,22 @@ def load_profile(self, name: str | None = None): if not ok or not name: return + namespace = self.profile_namespace prev_name = getattr(self, "_current_profile_name", None) skip_pair = getattr(self, "_pending_autosave_skip", None) if prev_name and prev_name != name: if skip_pair and skip_pair == (prev_name, name): self._pending_autosave_skip = None else: - us_prev = open_user_settings(prev_name) - self._write_snapshot_to_settings(us_prev, save_preview=False) - - # Choose source settings: user first, else default - if os.path.exists(user_profile_path(name)): - settings = open_user_settings(name) - elif os.path.exists(default_profile_path(name)): - settings = open_default_settings(name) - else: + us_prev = open_user_settings(prev_name, namespace=namespace) + self._write_snapshot_to_settings(us_prev, save_preview=True) + + settings = None + if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)): + settings = open_user_settings(name, namespace=namespace) + elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): + settings = open_default_settings(name, namespace=namespace) + if settings is None: QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") return @@ -815,19 +680,13 @@ def load_profile(self, name: str | None = None): area=QtAds.DockWidgetArea.RightDockWidgetArea, ) - geom = settings.value(SETTINGS_KEYS["geom"]) - if geom: - self.restoreGeometry(geom) - dock_state = settings.value(SETTINGS_KEYS["ads_state"]) - if dock_state: - self.dock_manager.restoreState(dock_state) - self.dock_manager.loadPerspectives(settings) + self.load_from_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.load_state(settings=settings) self._set_editable(self._editable) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name) + set_last_profile(name, namespace=namespace) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -844,6 +703,7 @@ def restore_user_profile_from_default(self, name: str | None = None): target = name or getattr(self, "_current_profile_name", None) if not target: return + namespace = self.profile_namespace current_pixmap = None if self.isVisible(): @@ -851,13 +711,13 @@ def restore_user_profile_from_default(self, name: str | None = None): ba = bytes(self.screenshot_bytes()) current_pixmap.loadFromData(ba) if current_pixmap is None or current_pixmap.isNull(): - current_pixmap = load_user_profile_screenshot(target) - default_pixmap = load_default_profile_screenshot(target) + current_pixmap = load_user_profile_screenshot(target, namespace=namespace) + default_pixmap = load_default_profile_screenshot(target, namespace=namespace) if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap): return - restore_user_from_default(target) + restore_user_from_default(target, namespace=namespace) self.delete_all() self.load_profile(target) @@ -871,6 +731,13 @@ def delete_profile(self): if not name: return + # Protect bundled/module/plugin profiles from deletion + if is_profile_read_only(name, namespace=self.profile_namespace): + QMessageBox.information( + self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." + ) + return + # Confirm deletion for regular profiles reply = QMessageBox.question( self, @@ -883,11 +750,8 @@ def delete_profile(self): if reply != QMessageBox.Yes: return - file_path = user_profile_path(name) - try: - os.remove(file_path) - except FileNotFoundError: - return + namespace = self.profile_namespace + delete_profile_files(name, namespace=namespace) self._refresh_workspace_list() def _refresh_workspace_list(self): @@ -896,17 +760,16 @@ def _refresh_workspace_list(self): """ combo = self.toolbar.components.get_action("workspace_combo").widget active_profile = getattr(self, "_current_profile_name", None) + namespace = self.profile_namespace + if hasattr(combo, "set_quick_profile_provider"): + combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns)) if hasattr(combo, "refresh_profiles"): combo.refresh_profiles(active_profile) else: # Fallback for regular QComboBox - from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - list_quick_profiles, - ) - combo.blockSignals(True) combo.clear() - quick_profiles = list_quick_profiles() + quick_profiles = list_quick_profiles(namespace=namespace) items = list(quick_profiles) if active_profile and active_profile not in items: items.insert(0, active_profile) @@ -968,46 +831,70 @@ def mode(self) -> str: @mode.setter def mode(self, new_mode: str): - if new_mode not in ["plot", "device", "utils", "developer", "user"]: + allowed_modes = ["plot", "device", "utils", "user", "creator"] + if new_mode not in allowed_modes: raise ValueError(f"Invalid mode: {new_mode}") self._mode = new_mode self.mode_changed.emit(new_mode) - - # Update toolbar visibility based on mode - if new_mode == "user": - # User mode: show only essential tools - self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) - elif new_mode == "developer": - # Developer mode: show all tools (use menu bundles) - self.toolbar.show_bundles( - [ - "menu_plots", - "menu_devices", - "menu_utils", - "spacer_bundle", - "workspace", - "dock_actions", - ] - ) - elif new_mode in ["plot", "device", "utils"]: - # Specific modes: show flat toolbar for that category - bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils" - self.toolbar.show_bundles([bundle_name]) - # self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"]) + self._apply_toolbar_layout() + + def _apply_toolbar_layout(self) -> None: + mode_key = getattr(self, "_mode", "creator") + if mode_key == "user": + bundles = ["spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "creator": + bundles = [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + elif mode_key == "plot": + bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "device": + bundles = ["flat_devices", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "utils": + bundles = ["flat_utils", "spacer_bundle", "workspace", "dock_actions"] else: - # Fallback to user mode - self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + bundles = ["spacer_bundle", "workspace", "dock_actions"] + + if not self._profile_management_enabled: + flat_only = [b for b in bundles if b.startswith("flat_")] + if not flat_only: + flat_only = ["flat_plots", "flat_devices", "flat_utils"] + bundles = flat_only + + self.toolbar.show_bundles(bundles) + + def prepare_for_shutdown(self) -> None: + """ + Persist the current workspace snapshot while the UI is still fully visible. + Called by the main window before initiating widget teardown to avoid capturing + close-triggered visibility changes. + """ + if ( + not self._auto_save_upon_exit + or getattr(self, "_exit_snapshot_written", False) + or getattr(self, "_destroyed", False) + ): + logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)") + return + + name = self._active_profile_name_or_default() + + namespace = self.profile_namespace + settings = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(settings) + set_last_profile(name, namespace=namespace) + self._exit_snapshot_written = True def cleanup(self): """ Cleanup the dock area. """ - # before cleanup save current profile (user copy) - name = getattr(self, "_current_profile_name", None) - if name: - us = open_user_settings(name) - self._write_snapshot_to_settings(us) - set_last_profile(name) + self.prepare_for_shutdown() if self.manage_dialog is not None: self.manage_dialog.reject() self.manage_dialog = None @@ -1025,7 +912,7 @@ def cleanup(self): apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="developer", root_widget=True) + ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True) window.setCentralWidget(ads) window.show() window.resize(800, 600) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py new file mode 100644 index 000000000..c839b6139 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -0,0 +1,1368 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Callable, Literal, Mapping, Sequence, cast + +from bec_qthemes import material_icon +from PySide6QtAds import ads +from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget +from shiboken6 import isValid + +import bec_widgets.widgets.containers.ads as QtAds +from bec_widgets import BECWidget, SafeSlot +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler +from bec_widgets.utils.property_editor import PropertyEditor +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.ads import ( + CDockAreaWidget, + CDockManager, + CDockSplitter, + CDockWidget, +) + + +class DockSettingsDialog(QDialog): + """Generic settings editor shown from dock title bar actions.""" + + def __init__(self, parent: QWidget, target: QWidget): + super().__init__(parent) + self.setWindowTitle("Dock Settings") + self.setModal(True) + layout = QVBoxLayout(self) + self.prop_editor = PropertyEditor(target, self, show_only_bec=True) + layout.addWidget(self.prop_editor) + + +class DockAreaWidget(BECWidget, QWidget): + """ + Lightweight dock area that exposes the core Qt ADS docking helpers without any + of the toolbar or workspace management features that the advanced variant offers. + """ + + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "dock_map", + "dock_list", + "widget_map", + "widget_list", + "attach_all", + "delete_all", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", + "set_central_dock", + ] + + @dataclass + class DockCreationSpec: + widget: QWidget + closable: bool = True + floatable: bool = True + movable: bool = True + start_floating: bool = False + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea + on_close: Callable[[CDockWidget, QWidget], None] | None = None + tab_with: CDockWidget | None = None + relative_to: CDockWidget | None = None + title_visible: bool | None = None + title_buttons: Mapping[ads.TitleBarButton, bool] | None = None + show_settings_action: bool | None = False + dock_preferences: Mapping[str, Any] | None = None + promote_central: bool = False + dock_icon: QIcon | None = None + apply_widget_icon: bool = True + + def __init__( + self, + parent: QWidget | None = None, + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + title: str = "Dock Area", + variant: Literal["cards", "compact"] = "cards", + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + + # Set variant property for styling + + if title: + self.setWindowTitle(title) + + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.setProperty("variant", variant) + + self._locked = False + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + self._root_layout.addWidget(self.dock_manager, 1) + + ################################################################################ + # Dock Utility Helpers + ################################################################################ + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Translate a direction string into a Qt ADS dock widget area.""" + direction = (where or self._default_add_direction or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(direction, QtAds.DockWidgetArea.RightDockWidgetArea) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + """Hook for subclasses to customise the dock before it is shown.""" + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) or {} + show_settings = prefs.get("show_settings_action") + if show_settings: + self._install_dock_settings_action(dock, widget) + + def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: + """Attach a dock-level settings action if available.""" + if getattr(dock, "setting_action", None) is not None: + return + + action = MaterialIconAction( + icon_name="settings", tooltip="Dock settings", filled=True, parent=self + ).action + action.setObjectName("dockSettingsAction") + action.setToolTip("Dock settings") + action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + + existing = list(dock.titleBarActions()) + existing.append(action) + dock.setTitleBarActions(existing) + dock.setting_action = action + + def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: + """Launch the property editor dialog for the dock's widget.""" + dlg = DockSettingsDialog(self, widget) + dlg.resize(600, 600) + dlg.exec() + + ################################################################################ + # Dock Lifecycle + ################################################################################ + + def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None: + """Default dock close routine used when no custom handler is provided.""" + widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + + def close_dock(self, dock: CDockWidget, widget: QWidget | None = None) -> None: + """ + Helper for custom close handlers to invoke the default close behaviour. + + Args: + dock: Dock widget to close. + widget: Optional widget contained in the dock; resolved automatically when not given. + """ + target_widget = widget or dock.widget() + if target_widget is None: + return + self._default_close_handler(dock, target_widget) + + def _wrap_close_candidate( + self, candidate: Callable, widget: QWidget + ) -> Callable[[CDockWidget], None]: + """ + Wrap a user-provided close handler to adapt its signature. + + Args: + candidate(Callable): User-provided close handler. + widget(QWidget): Widget contained in the dock. + + Returns: + Callable[[CDockWidget], None]: Wrapped close handler. + """ + try: + sig = inspect.signature(candidate) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values() + ) + positional_params = [ + p + for p in sig.parameters.values() + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + except (ValueError, TypeError): + accepts_varargs = True + positional_params = [] + + positional_count = len(positional_params) + + def invoke(dock: CDockWidget) -> None: + try: + if accepts_varargs or positional_count >= 2: + candidate(dock, widget) + elif positional_count == 1: + candidate(dock) + else: + candidate() + except TypeError: + # Best effort fallback in case the signature inspection was misleading. + candidate(dock, widget) + + return invoke + + def _resolve_close_handler( + self, widget: QWidget, on_close: Callable[[CDockWidget, QWidget], None] | None = None + ) -> Callable[[CDockWidget], None]: + """ + Determine which close handler to use for a dock. + Priority: + 1. Explicit `on_close` callable passed to `new`. + 2. Widget attribute `handle_dock_close` or `on_dock_close` if callable. + 3. Default close handler. + + Args: + widget(QWidget): The widget contained in the dock. + on_close(Callable[[CDockWidget, QWidget], None] | None): Explicit close handler. + + Returns: + Callable[[CDockWidget], None]: Resolved close handler. + """ + + candidate = on_close + if candidate is None: + candidate = getattr(widget, "handle_dock_close", None) + if candidate is None: + candidate = getattr(widget, "on_dock_close", None) + + if callable(candidate): + return self._wrap_close_candidate(candidate, widget) + + return lambda dock: self._default_close_handler(dock, widget) + + def _make_dock( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool = True, + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, + start_floating: bool = False, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | None = None, + relative_to: CDockWidget | None = None, + dock_preferences: Mapping[str, Any] | None = None, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + ) -> CDockWidget: + """ + Create and add a new dock widget to the area. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + area(QtAds.DockWidgetArea): Target dock area. + start_floating(bool): Whether the dock should start floating. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | None): Optional dock to tab with. + relative_to(CDockWidget | None): Optional dock to position relative to. + dock_preferences(Mapping[str, Any] | None): Appearance preferences to apply. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + CDockWidget: Created dock widget. + """ + if not widget.objectName(): + widget.setObjectName(widget.__class__.__name__) + + if tab_with is not None and relative_to is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + dock = CDockWidget(self.dock_manager, widget.objectName(), self) + dock.setWidget(widget) + dock._dock_preferences = dict(dock_preferences or {}) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, closable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, floatable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, movable) + + self._customize_dock(dock, widget) + resolved_icon = self._resolve_dock_icon(widget, dock_icon, apply_widget_icon) + + close_handler = self._resolve_close_handler(widget, on_close) + + def on_widget_destroyed(): + if not isValid(dock): + return + dock.closeDockWidget() + dock.deleteDockWidget() + + dock.closeRequested.connect(lambda: close_handler(dock)) + if hasattr(widget, "widget_removed"): + widget.widget_removed.connect(on_widget_destroyed) + + dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) + dock_area_widget = None + if tab_with is not None: + if not isValid(tab_with): + raise ValueError("Tab target dock widget is not valid anymore.") + dock_area_widget = tab_with.dockAreaWidget() + + if dock_area_widget is not None: + self.dock_manager.addDockWidgetTabToArea(dock, dock_area_widget) + else: + target_area_widget = None + if relative_to is not None: + if not isValid(relative_to): + raise ValueError("Relative target dock widget is not valid anymore.") + target_area_widget = relative_to.dockAreaWidget() + self.dock_manager.addDockWidget(area, dock, target_area_widget) + + if start_floating and tab_with is None and not promote_central: + dock.setFloating() + if resolved_icon is not None: + dock.setIcon(resolved_icon) + return dock + + def _delete_dock(self, dock: CDockWidget) -> None: + widget = dock.widget() + if widget and isValid(widget): + widget.close() + widget.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + def _resolve_dock_reference( + self, ref: CDockWidget | QWidget | str | None, *, allow_none: bool = True + ) -> CDockWidget | None: + """ + Resolve a dock reference from various input types. + + Args: + ref(CDockWidget | QWidget | str | None): Dock reference. + allow_none(bool): Whether to allow None as a valid return value. + + Returns: + CDockWidget | None: Resolved dock widget or None. + """ + if ref is None: + if allow_none: + return None + raise ValueError("Dock reference cannot be None.") + if isinstance(ref, CDockWidget): + if not isValid(ref): + raise ValueError("Dock widget reference is not valid anymore.") + return ref + if isinstance(ref, QWidget): + for dock in self.dock_list(): + if dock.widget() is ref: + return dock + raise ValueError("Widget reference is not associated with any dock in this area.") + if isinstance(ref, str): + dock_map = self.dock_map() + dock = dock_map.get(ref) + if dock is None: + raise ValueError(f"No dock found with objectName '{ref}'.") + return dock + raise TypeError( + "Dock reference must be a CDockWidget, QWidget, object name string, or None." + ) + + ################################################################################ + # Splitter Handling + ################################################################################ + + def _resolve_dock_icon( + self, widget: QWidget, dock_icon: QIcon | None, apply_widget_icon: bool + ) -> QIcon | None: + """ + Choose an icon for the dock: prefer an explicitly provided one, otherwise + fall back to the widget's `ICON_NAME` (material icons) when available. + + Args: + widget(QWidget): The widget to dock. + dock_icon(QIcon | None): Explicit icon to use for the dock. + + Returns: + QIcon | None: Resolved dock icon, or None if not available. + """ + + if dock_icon is not None: + return dock_icon + if not apply_widget_icon: + return None + icon_name = getattr(widget, "ICON_NAME", None) + if not icon_name: + return None + try: + return material_icon(icon_name, size=(24, 24), convert_to_pixmap=False) + except Exception: + return None + + def _build_creation_spec( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool, + start_floating: bool, + where: Literal["left", "right", "top", "bottom"] | None, + on_close: Callable[[CDockWidget, QWidget], None] | None, + tab_with: CDockWidget | QWidget | str | None, + relative_to: CDockWidget | QWidget | str | None, + show_title_bar: bool | None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None, + show_settings_action: bool | None, + promote_central: bool, + dock_icon: QIcon | None, + apply_widget_icon: bool, + ) -> DockCreationSpec: + """ + Normalize and validate dock creation parameters into a spec object. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + start_floating(bool): Whether the dock should start floating. + where(Literal["left", "right", "top", "bottom"] | None): Target dock area. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. + relative_to(CDockWidget | QWidget | str | None): Optional dock to position relative to. + show_title_bar(bool | None): Whether to show the dock title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Title bar buttons to show/hide. + show_settings_action(bool | None): Whether to show the dock settings action. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + DockCreationSpec: Normalized dock creation specification. + + """ + normalized_buttons = self._normalize_title_buttons(title_buttons) + resolved_tab = self._resolve_dock_reference(tab_with) + resolved_relative = self._resolve_dock_reference(relative_to) + + if resolved_tab is not None and resolved_relative is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + target_area = self._area_from_where(where) + if resolved_relative is not None and where is None: + inferred = self.dock_manager.dockWidgetArea(resolved_relative) + if inferred in ( + QtAds.DockWidgetArea.InvalidDockWidgetArea, + QtAds.DockWidgetArea.NoDockWidgetArea, + ): + inferred = self._area_from_where(None) + target_area = inferred + + dock_preferences = { + "show_title_bar": show_title_bar, + "title_buttons": normalized_buttons if normalized_buttons else None, + "show_settings_action": show_settings_action, + } + dock_preferences = {k: v for k, v in dock_preferences.items() if v is not None} + + return self.DockCreationSpec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + on_close=on_close, + tab_with=resolved_tab, + relative_to=resolved_relative, + title_visible=show_title_bar, + title_buttons=normalized_buttons if normalized_buttons else None, + show_settings_action=show_settings_action, + dock_preferences=dock_preferences or None, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: + """ + Create a dock from a normalized spec and apply preferences. + + Args: + spec(DockCreationSpec): Dock creation specification. + + Returns: + CDockWidget: Created dock widget. + """ + dock = self._make_dock( + spec.widget, + closable=spec.closable, + floatable=spec.floatable, + movable=spec.movable, + area=spec.area, + start_floating=spec.start_floating, + on_close=spec.on_close, + tab_with=spec.tab_with, + relative_to=spec.relative_to, + dock_preferences=spec.dock_preferences, + promote_central=spec.promote_central, + dock_icon=spec.dock_icon, + apply_widget_icon=spec.apply_widget_icon, + ) + self.dock_manager.setFocus() + self._apply_dock_preferences(dock) + if spec.promote_central: + self.set_central_dock(dock) + return dock + + def _coerce_weights( + self, + weights: Sequence[float] | Mapping[int | str, float] | None, + count: int, + orientation: Qt.Orientation, + ) -> list[float] | None: + """ + Normalize weight specs into a list matching splitter child count. + + Args: + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + count(int): Number of splitter children. + orientation(Qt.Orientation): Splitter orientation. + + Returns: + list[float] | None: Normalized weight list, or None if invalid. + """ + if weights is None or count <= 0: + return None + + result: list[float] + if isinstance(weights, (list, tuple)): + result = [float(v) for v in weights[:count]] + elif isinstance(weights, Mapping): + default = float(weights.get("default", 1.0)) + result = [default] * count + + alias: dict[str, int] = {} + if count >= 1: + alias["first"] = 0 + alias["start"] = 0 + if count >= 2: + alias["last"] = count - 1 + alias["end"] = count - 1 + if orientation == Qt.Orientation.Horizontal: + alias["left"] = 0 + alias["right"] = count - 1 + if count >= 3: + alias["center"] = count // 2 + alias["middle"] = count // 2 + else: + alias["top"] = 0 + alias["bottom"] = count - 1 + + for key, value in weights.items(): + if key == "default": + continue + idx: int | None = None + if isinstance(key, int): + idx = key + elif isinstance(key, str): + lowered = key.lower() + if lowered in alias: + idx = alias[lowered] + elif lowered.startswith("col"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + elif lowered.startswith("row"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + if idx is not None and 0 <= idx < count: + result[idx] = float(value) + else: + return None + + if len(result) < count: + result += [1.0] * (count - len(result)) + result = result[:count] + if all(v <= 0 for v in result): + result = [1.0] * count + return result + + def _schedule_splitter_weights( + self, + splitter: QtAds.CDockSplitter, + weights: Sequence[float] | Mapping[int | str, float] | None, + ) -> None: + """ + Apply weight ratios to a splitter once geometry is available. + + Args: + splitter(QtAds.CDockSplitter): Target splitter. + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + """ + if splitter is None or weights is None: + return + + ratios = self._coerce_weights(weights, splitter.count(), splitter.orientation()) + if not ratios: + return + + def apply(): + count = splitter.count() + if count != len(ratios): + return + + orientation = splitter.orientation() + total_px = ( + splitter.width() if orientation == Qt.Orientation.Horizontal else splitter.height() + ) + if total_px <= count: + QTimer.singleShot(0, apply) + return + + total = sum(ratios) + if total <= 0: + return + sizes = [max(1, int(round(total_px * (r / total)))) for r in ratios] + diff = total_px - sum(sizes) + if diff: + idx = max(range(count), key=lambda i: ratios[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, weight in enumerate(ratios): + splitter.setStretchFactor(i, max(1, int(round(weight * 100)))) + + QTimer.singleShot(0, apply) + + def _normalize_override_keys( + self, + overrides: Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]], + ) -> dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + """ + Normalize various key types into tuple paths. + + Args: + overrides(Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]]): + Original overrides mapping. + + Returns: + dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + Normalized overrides mapping. + """ + normalized: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]] = {} + for key, value in overrides.items(): + path: tuple[int, ...] | None = None + if isinstance(key, int): + path = (key,) + elif isinstance(key, (list, tuple)): + try: + path = tuple(int(k) for k in key) + except ValueError: + continue + elif isinstance(key, str): + cleaned = key.replace(" ", "").replace(".", "/") + if cleaned in ("", "/"): + path = () + else: + parts = [p for p in cleaned.split("/") if p] + try: + path = tuple(int(p) for p in parts) + except ValueError: + continue + if path is not None: + normalized[path] = value + return normalized + + def _apply_splitter_tree( + self, + splitter: QtAds.CDockSplitter, + path: tuple[int, ...], + horizontal: Sequence[float] | Mapping[int | str, float] | None, + vertical: Sequence[float] | Mapping[int | str, float] | None, + overrides: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]], + ) -> None: + """Traverse splitter hierarchy and apply ratios.""" + orientation = splitter.orientation() + base_weights = horizontal if orientation == Qt.Orientation.Horizontal else vertical + + override = None + if overrides: + if path in overrides: + override = overrides[path] + elif len(path) >= 1: + key = (path[-1],) + if key in overrides: + override = overrides[key] + + self._schedule_splitter_weights(splitter, override or base_weights) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, QtAds.CDockSplitter): + self._apply_splitter_tree(child, path + (idx,), horizontal, vertical, overrides) + + ################################################################################ + # Layout Inspection + ################################################################################ + + def _collect_splitter_info( + self, + splitter: CDockSplitter, + path: tuple[int, ...], + results: list[dict[str, Any]], + container_index: int, + ) -> None: + orientation = ( + "horizontal" if splitter.orientation() == Qt.Orientation.Horizontal else "vertical" + ) + entry: dict[str, Any] = { + "container": container_index, + "path": path, + "orientation": orientation, + "children": [], + } + results.append(entry) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, CDockSplitter): + entry["children"].append({"index": idx, "type": "splitter"}) + self._collect_splitter_info(child, path + (idx,), results, container_index) + elif isinstance(child, CDockAreaWidget): + docks = [dock.objectName() for dock in child.dockWidgets()] + entry["children"].append({"index": idx, "type": "dock_area", "docks": docks}) + elif isinstance(child, CDockWidget): + entry["children"].append({"index": idx, "type": "dock", "name": child.objectName()}) + else: + entry["children"].append({"index": idx, "type": child.__class__.__name__}) + + def describe_layout(self) -> list[dict[str, Any]]: + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + info: list[dict[str, Any]] = [] + for container_index, container in enumerate(self.dock_manager.dockContainers()): + splitter = container.rootSplitter() + if splitter is None: + continue + self._collect_splitter_info(splitter, (), info, container_index) + return info + + def print_layout_structure(self) -> None: + """Pretty-print the current splitter paths to stdout.""" + for entry in self.describe_layout(): + children_desc = [] + for child in entry["children"]: + if child["type"] == "dock_area": + children_desc.append( + f"{child['index']}:dock_area[{', '.join(child['docks']) or '-'}]" + ) + elif child["type"] == "dock": + children_desc.append(f"{child['index']}:dock({child['name']})") + else: + children_desc.append(f"{child['index']}:{child['type']}") + summary = ", ".join(children_desc) + print( + f"container={entry['container']} path={entry['path']} " + f"orientation={entry['orientation']} -> [{summary}]" + ) + + ################################################################################ + # State Persistence + ################################################################################ + + @staticmethod + def _coerce_byte_array(value: Any) -> QByteArray | None: + """Best-effort conversion of arbitrary values into a QByteArray.""" + if isinstance(value, QByteArray): + return QByteArray(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return QByteArray(bytes(value)) + return None + + @staticmethod + def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[str, str | None]: + """ + Merge caller overrides with sensible defaults. + + Only `geom`, `state`, and `ads_state` are recognised. Missing entries default to: + geom -> "dock_area/geometry" + state -> None (skip writing legacy main window state) + ads_state -> "dock_area/docking_state" + """ + defaults: dict[str, str | None] = { + "geom": "dock_area/geometry", + "state": None, + "ads_state": "dock_area/docking_state", + } + if overrides: + for key, value in overrides.items(): + if key in defaults: + defaults[key] = value + return defaults + + def save_to_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Persist the current dock layout into an existing `QSettings` instance. + + Args: + settings(QSettings): Target QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + settings.setValue(geom_key, self.saveGeometry()) + + legacy_state_key = resolved.get("state") + if legacy_state_key: + settings.setValue(legacy_state_key, b"") + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + settings.setValue(ads_state_key, self.dock_manager.saveState()) + + if include_perspectives: + name = perspective_name or self.windowTitle() + if name: + self.dock_manager.addPerspective(name) + self.dock_manager.savePerspectives(settings) + + def save_to_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Convenience wrapper around `save_to_settings` that opens a temporary QSettings. + + Args: + path(str): File path to save the settings to. + format(QSettings.Format): File format to use. + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + settings = QSettings(path, format) + self.save_to_settings( + settings, + keys=keys, + include_perspectives=include_perspectives, + perspective_name=perspective_name, + ) + settings.sync() + + def load_from_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Restore the dock layout from a `QSettings` instance previously populated by `save_to_settings`. + + Args: + settings(QSettings): Source QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + restore_perspectives(bool): When True, restore Qt ADS perspectives alongside the layout. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + geom_value = settings.value(geom_key) + geom_bytes = self._coerce_byte_array(geom_value) + if geom_bytes is not None: + self.restoreGeometry(geom_bytes) + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + dock_state = settings.value(ads_state_key) + dock_bytes = self._coerce_byte_array(dock_state) + if dock_bytes is not None: + self.dock_manager.restoreState(dock_bytes) + + if restore_perspectives: + self.dock_manager.loadPerspectives(settings) + + def load_from_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Convenience wrapper around `load_from_settings` that reads from a file path. + """ + settings = QSettings(path, format) + self.load_from_settings(settings, keys=keys, restore_perspectives=restore_perspectives) + + def set_layout_ratios( + self, + *, + horizontal: Sequence[float] | Mapping[int | str, float] | None = None, + vertical: Sequence[float] | Mapping[int | str, float] | None = None, + splitter_overrides: ( + Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None + ) = None, + ) -> None: + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + overrides = self._normalize_override_keys(splitter_overrides) if splitter_overrides else {} + + for container in self.dock_manager.dockContainers(): + splitter = container.rootSplitter() + if splitter is None: + continue + self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) + + @staticmethod + def _title_bar_button_enum(name: str) -> ads.TitleBarButton | None: + """Translate a user-friendly button name into an ADS TitleBarButton enum.""" + normalized = (name or "").lower().replace("-", "_").replace(" ", "_") + mapping: dict[str, ads.TitleBarButton] = { + "menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": ads.TitleBarButton.TitleBarButtonUndock, + "float": ads.TitleBarButton.TitleBarButtonUndock, + "detach": ads.TitleBarButton.TitleBarButtonUndock, + "close": ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": ads.TitleBarButton.TitleBarButtonMinimize, + } + return mapping.get(normalized) + + def _normalize_title_buttons( + self, + spec: ( + Mapping[str | ads.TitleBarButton, bool] + | Sequence[str | ads.TitleBarButton] + | str + | ads.TitleBarButton + | None + ), + ) -> dict[ads.TitleBarButton, bool]: + """Normalize button visibility specifications into an enum mapping.""" + if spec is None: + return {} + + result: dict[ads.TitleBarButton, bool] = {} + if isinstance(spec, Mapping): + iterator = spec.items() + else: + if isinstance(spec, str): + spec = [spec] + iterator = ((name, False) for name in spec) + + for name, visible in iterator: + if isinstance(name, ads.TitleBarButton): + enum = name + else: + enum = self._title_bar_button_enum(str(name)) + if enum is None: + continue + result[enum] = bool(visible) + return result + + def _apply_dock_preferences(self, dock: CDockWidget) -> None: + """ + Apply deferred appearance preferences to a dock once it has been created. + + Args: + dock(CDockWidget): Target dock widget. + """ + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) + + def apply(): + title_bar = None + area_widget = dock.dockAreaWidget() + if area_widget is not None and hasattr(area_widget, "titleBar"): + title_bar = area_widget.titleBar() + + show_title_bar = prefs.get("show_title_bar") + if title_bar is not None and show_title_bar is not None: + title_bar.setVisible(bool(show_title_bar)) + + button_prefs = prefs.get("title_buttons") or {} + if title_bar is not None and button_prefs: + for enum, visible in button_prefs.items(): + try: + button = title_bar.button(enum) + except Exception: # pragma: no cover - defensive against ADS API changes + button = None + if button is not None: + button.setVisible(bool(visible)) + + # single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize + QTimer.singleShot(0, apply) + + def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None: + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + resolved = self._resolve_dock_reference(dock, allow_none=False) + self.dock_manager.setCentralWidget(resolved) + self._apply_dock_preferences(resolved) + + ################################################################################ + # Public API + ################################################################################ + + @SafeSlot(popup_error=True) + def new( + self, + widget: QWidget | str, + *, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | QWidget | str | None = None, + relative_to: CDockWidget | QWidget | str | None = None, + return_dock: bool = False, + show_title_bar: bool | None = None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, + show_settings_action: bool | None = False, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + if isinstance(widget, str): + if return_dock: + raise ValueError( + "return_dock=True is not supported when creating widgets by type name." + ) + widget = cast( + BECWidget, + widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs), + ) + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _on_name_established(_name: str) -> None: + # Defer creation so BECConnector sibling name enforcement has completed. + QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) + + widget.name_established.connect(_on_name_established) + return widget + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + dock = self._create_dock_from_spec(spec) + return dock if return_dock else widget + + def dock_map(self) -> dict[str, CDockWidget]: + """Return the dock widgets map as dictionary with names as keys.""" + return self.dock_manager.dockWidgetsMap() + + def dock_list(self) -> list[CDockWidget]: + """Return the list of dock widgets.""" + return self.dock_manager.dockWidgets() + + def widget_map(self) -> dict[str, QWidget]: + """Return a dictionary mapping widget names to their corresponding widgets.""" + return {dock.objectName(): dock.widget() for dock in self.dock_list()} + + def widget_list(self) -> list[QWidget]: + """Return a list of all widgets contained in the dock area.""" + return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] + + @SafeSlot() + def attach_all(self): + """Re-attach floating docks back into the dock manager.""" + for container in self.dock_manager.floatingWidgets(): + docks = container.dockWidgets() + if not docks: + continue + target = docks[0] + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) + for dock in docks[1:]: + self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.RightDockWidgetArea, dock, target + ) + + @SafeSlot() + def delete_all(self): + """Delete all docks and their associated widgets.""" + for dock in list(self.dock_manager.dockWidgets()): + self._delete_dock(dock) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton + + from bec_widgets.utils.colors import apply_theme + + class CustomCloseWidget(QWidget): + """Example widget showcasing custom close handling via handle_dock_close.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("CustomCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom close handler – tabbed with Column 1 / Row 1.\n" + "Close this dock to see the stdout cleanup message.", + self, + ) + ) + btn = QPushButton("Click me before closing", self) + layout.addWidget(btn) + + def handle_dock_close(self, dock: CDockWidget, widget: QWidget) -> None: + print(f"[CustomCloseWidget] Closing {widget.objectName()}") + area = widget.parent() + while area is not None and not isinstance(area, DockAreaWidget): + area = area.parent() + if isinstance(area, DockAreaWidget): + area.close_dock(dock, widget) + + class LambdaCloseWidget(QWidget): + """Example widget that relies on an explicit lambda passed to BasicDockArea.new.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("LambdaCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom lambda close handler – tabbed with Column 2 / Row 1.\n" + "Closing prints which dock triggered the callback.", + self, + ) + ) + + app = QApplication(sys.argv) + apply_theme("dark") + window = QMainWindow() + area = DockAreaWidget(root_widget=True, title="Basic Dock Area Demo") + window.setCentralWidget(area) + window.resize(1400, 800) + window.show() + + def make_panel(name: str, title: str, body: str = "") -> QWidget: + panel = QWidget() + panel.setObjectName(name) + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(title, panel)) + if body: + layout.addWidget(QLabel(body, panel)) + layout.addStretch(1) + return panel + + # Column 1: plain 'where' usage + col1_top = area.new( + make_panel("C1R1", "Column 1 / Row 1", "Added with where='left'."), + closable=True, + where="left", + return_dock=True, + show_settings_action=True, + ) + area.new( + make_panel("C1R2", "Column 1 / Row 2", "Stacked via relative_to + where='bottom'."), + closable=True, + where="bottom", + relative_to=col1_top, + ) + + # Column 2: relative placement and tabbing + col2_top = area.new( + make_panel( + "C2R1", "Column 2 / Row 1", "Placed to the right of Column 1 using relative_to." + ), + closable=True, + where="right", + relative_to=col1_top, + return_dock=True, + ) + area.new( + make_panel("C2R2", "Column 2 / Row 2", "Added beneath Column 2 / Row 1 via relative_to."), + closable=True, + where="bottom", + relative_to=col2_top, + ) + area.new( + make_panel("C2Tabbed", "Column 2 / Tabbed", "Tabbed with Column 2 / Row 1 using tab_with."), + closable=True, + tab_with=col2_top, + ) + + # Column 3: mix of where, relative_to, and custom close handler + col3_top = area.new( + make_panel("C3R1", "Column 3 / Row 1", "Placed to the right of Column 2 via relative_to."), + closable=True, + where="right", + relative_to=col2_top, + return_dock=True, + ) + area.new( + make_panel( + "C3R2", "Column 3 / Row 2", "Plain where='bottom' relative to Column 3 / Row 1." + ), + closable=True, + where="bottom", + relative_to=col3_top, + ) + area.new( + make_panel( + "C3Lambda", + "Column 3 / Tabbed Lambda", + "Tabbed with Column 3 / Row 1. Custom close handler prints the dock name.", + ), + closable=True, + tab_with=col3_top, + on_close=lambda dock, widget: ( + print(f"[Lambda handler] Closing {widget.objectName()}"), + area.close_dock(dock, widget), + ), + show_settings_action=True, + ) + + area.set_layout_ratios( + horizontal=[1, 1.5, 1], splitter_overrides={0: [3, 2], 1: [4, 3], 2: [2, 1]} + ) + + print("\nSplitter structure (paths for splitter_overrides):") + area.print_layout_structure() + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index a49183ffe..59c4847bd 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -10,11 +10,13 @@ from __future__ import annotations import os +import re import shutil from functools import lru_cache from pathlib import Path from typing import Literal +from bec_lib import bec_logger from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field @@ -22,18 +24,32 @@ from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +logger = bec_logger.logger + MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] def module_profiles_dir() -> str: - """Return the read-only module-bundled profiles directory (no writes here).""" + """ + Return the built-in AdvancedDockArea profiles directory bundled with the module. + + Returns: + str: Absolute path of the read-only module profiles directory. + """ return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles") @lru_cache(maxsize=1) def _plugin_repo_root() -> Path | None: + """ + Resolve the plugin repository root path if running inside a plugin context. + + Returns: + Path | None: Root path of the active plugin repository, or ``None`` when + no plugin context is detected. + """ try: return Path(plugin_repo_path()) except ValueError: @@ -42,6 +58,13 @@ def _plugin_repo_root() -> Path | None: @lru_cache(maxsize=1) def _plugin_display_name() -> str | None: + """ + Determine a user-friendly plugin name for provenance labels. + + Returns: + str | None: Human-readable name inferred from the plugin repo or package, + or ``None`` if it cannot be determined. + """ repo_root = _plugin_repo_root() if not repo_root: return None @@ -57,7 +80,13 @@ def _plugin_display_name() -> str | None: @lru_cache(maxsize=1) def plugin_profiles_dir() -> str | None: - """Return the read-only plugin-bundled profiles directory if available.""" + """ + Locate the read-only profiles directory shipped with a beamline plugin. + + Returns: + str | None: Directory containing bundled plugin profiles, or ``None`` if + no plugin profiles are available. + """ repo_root = _plugin_repo_root() if not repo_root: return None @@ -66,8 +95,8 @@ def plugin_profiles_dir() -> str | None: try: package_root = repo_root.joinpath(*plugin_package_name().split(".")) candidates.append(package_root.joinpath("bec_widgets", "profiles")) - except ValueError: - pass + except ValueError as e: + logger.error(f"Could not determine plugin package name: {e}") for candidate in candidates: if candidate.is_dir(): @@ -76,7 +105,12 @@ def plugin_profiles_dir() -> str | None: def _settings_profiles_root() -> str: - """Return the writable profiles root provided by BEC client (or env fallback).""" + """ + Resolve the writable profiles root provided by the BEC client. + + Returns: + str: Absolute path to the profiles root. The directory is created if missing. + """ client = BECClient() bec_widgets_settings = client._service_config.config.get("bec_widgets_settings") bec_widgets_setting_path = ( @@ -88,63 +122,282 @@ def _settings_profiles_root() -> str: return root -def default_profiles_dir() -> str: - path = os.path.join(_settings_profiles_root(), "default") - os.makedirs(path, exist_ok=True) - return path +def sanitize_namespace(namespace: str | None) -> str | None: + """ + Clean user-provided namespace labels for filesystem compatibility. + + Args: + namespace (str | None): Arbitrary namespace identifier supplied by the caller. + + Returns: + str | None: Sanitized namespace containing only safe characters, or ``None`` + when the input is empty. + """ + if not namespace: + return None + ns = namespace.strip() + if not ns: + return None + return re.sub(r"[^0-9A-Za-z._-]+", "_", ns) -def user_profiles_dir() -> str: - path = os.path.join(_settings_profiles_root(), "user") +def _profiles_dir(segment: str, namespace: str | None) -> str: + """ + Build (and ensure) the directory that holds profiles for a namespace segment. + + Args: + segment (str): Either ``"user"`` or ``"default"``. + namespace (str | None): Optional namespace label to scope profiles. + + Returns: + str: Absolute directory path for the requested segment/namespace pair. + """ + base = os.path.join(_settings_profiles_root(), segment) + ns = sanitize_namespace(namespace) + path = os.path.join(base, ns) if ns else base os.makedirs(path, exist_ok=True) return path -def default_profile_path(name: str) -> str: - return os.path.join(default_profiles_dir(), f"{name}.ini") +def _user_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate user-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate user profile paths (.ini files). + """ + ns = sanitize_namespace(namespace) + primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def _default_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate default-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate default profile paths (.ini files). + """ + ns = sanitize_namespace(namespace) + primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def default_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores default profiles for the namespace. + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. -def user_profile_path(name: str) -> str: - return os.path.join(user_profiles_dir(), f"{name}.ini") + Returns: + str: Absolute path to the default profile directory. + """ + return _profiles_dir("default", namespace) + + +def user_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores user profiles for the namespace. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile directory. + """ + return _profiles_dir("user", namespace) + + +def default_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical default profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the default profile file (.ini). + """ + return _default_path_candidates(name, namespace)[0] + + +def user_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical user profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile file (.ini). + """ + return _user_path_candidates(name, namespace)[0] + + +def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all user profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate user profile paths. + """ + return list(dict.fromkeys(_user_path_candidates(name, namespace))) + + +def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all default profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate default profile paths. + """ + return list(dict.fromkeys(_default_path_candidates(name, namespace))) + + +def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing user profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing user profile candidate, or ``None`` + when no files are present. + """ + for path in user_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None + + +def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing default profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing default profile candidate, or ``None`` + when no files are present. + """ + for path in default_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None def module_profile_path(name: str) -> str: + """ + Build the absolute path to a bundled module profile. + + Args: + name (str): Profile name without extension. + + Returns: + str: Absolute path to the module's read-only profile file. + """ return os.path.join(module_profiles_dir(), f"{name}.ini") def plugin_profile_path(name: str) -> str | None: + """ + Build the absolute path to a bundled plugin profile if available. + + Args: + name (str): Profile name without extension. + + Returns: + str | None: Absolute plugin profile path, or ``None`` when plugins do not + provide profiles. + """ directory = plugin_profiles_dir() if not directory: return None return os.path.join(directory, f"{name}.ini") -def profile_origin(name: str) -> ProfileOrigin: +def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin: """ Determine where a profile originates from. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + Returns: - ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles, - "settings" for user-defined ones, and "unknown" if no backing files are found. + ProfileOrigin: ``"module"`` for bundled BEC profiles, ``"plugin"`` for beamline + plugin bundles, ``"settings"`` for writable copies, and ``"unknown"`` when + no backing files are found. """ if os.path.exists(module_profile_path(name)): return "module" plugin_path = plugin_profile_path(name) if plugin_path and os.path.exists(plugin_path): return "plugin" - if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)): - return "settings" + for path in user_profile_candidates(name, namespace) + default_profile_candidates( + name, namespace + ): + if os.path.exists(path): + return "settings" return "unknown" -def is_profile_read_only(name: str) -> bool: - """Return True when the profile originates from bundled module or plugin directories.""" - return profile_origin(name) in {"module", "plugin"} +def is_profile_read_only(name: str, namespace: str | None = None) -> bool: + """ + Check whether a profile is read-only because it originates from bundles. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. -def profile_origin_display(name: str) -> str | None: - """Return a human-readable label for the profile's origin.""" - origin = profile_origin(name) + Returns: + bool: ``True`` if the profile originates from module or plugin bundles. + """ + return profile_origin(name, namespace) in {"module", "plugin"} + + +def profile_origin_display(name: str, namespace: str | None = None) -> str | None: + """ + Build a user-facing label describing a profile's origin. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + + Returns: + str | None: Localized display label such as ``"BEC Widgets"`` or ``"User"``, + or ``None`` when origin cannot be determined. + """ + origin = profile_origin(name, namespace) if origin == "module": return "BEC Widgets" if origin == "plugin": @@ -154,26 +407,39 @@ def profile_origin_display(name: str) -> str | None: return None -def delete_profile_files(name: str) -> bool: +def delete_profile_files(name: str, namespace: str | None = None) -> bool: """ Delete the profile files from the writable settings directories. - Removes both the user and default copies (if they exist) and clears the last profile - metadata when applicable. Returns True when at least one file was removed. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label scoped to the profile. Defaults + to ``None``. + + Returns: + bool: ``True`` if at least one file was removed. """ - if is_profile_read_only(name): - return False + read_only = is_profile_read_only(name, namespace) removed = False - for path in {user_profile_path(name), default_profile_path(name)}: + # Always allow removing user copies; keep default copies for read-only origins. + for path in set(user_profile_candidates(name, namespace)): try: os.remove(path) removed = True except FileNotFoundError: continue - if removed and get_last_profile() == name: - set_last_profile(None) + if not read_only: + for path in set(default_profile_candidates(name, namespace)): + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if removed and get_last_profile(namespace) == name: + set_last_profile(None, namespace) return removed @@ -191,12 +457,32 @@ def delete_profile_files(name: str) -> bool: } -def list_profiles() -> list[str]: - # Collect profiles from writable settings (default + user) - defaults = { - os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini") - } - users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")} +def list_profiles(namespace: str | None = None) -> list[str]: + """ + Enumerate all known profile names, syncing bundled defaults when missing locally. + + Args: + namespace (str | None, optional): Namespace label scoped to the profile set. + Defaults to ``None``. + + Returns: + list[str]: Sorted unique profile names. + """ + ns = sanitize_namespace(namespace) + + def _collect_from(directory: str) -> set[str]: + if not os.path.isdir(directory): + return set() + return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")} + + settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)} + if ns: + settings_dirs.add(default_profiles_dir(None)) + settings_dirs.add(user_profiles_dir(None)) + + settings_names: set[str] = set() + for directory in settings_dirs: + settings_names |= _collect_from(directory) # Also consider read-only defaults from core module and beamline plugin repositories read_only_sources: dict[str, tuple[str, str]] = {} @@ -214,62 +500,127 @@ def list_profiles() -> list[str]: read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) for name, (_origin, src) in sorted(read_only_sources.items()): - # Ensure a copy in the settings default directory so existing code paths work unchanged - dst_default = default_profile_path(name) + # Ensure a copy in the namespace-specific settings default directory + dst_default = default_profile_path(name, namespace) if not os.path.exists(dst_default): os.makedirs(os.path.dirname(dst_default), exist_ok=True) shutil.copyfile(src, dst_default) # Ensure a user copy exists to allow edits in the writable settings area - dst_user = user_profile_path(name) + dst_user = user_profile_path(name, namespace) if not os.path.exists(dst_user): os.makedirs(os.path.dirname(dst_user), exist_ok=True) shutil.copyfile(src, dst_user) - # Minimal metadata touch-up to align with existing expectations - s = open_user_settings(name) - if not s.value(SETTINGS_KEYS["created_at"], ""): + s = open_user_settings(name, namespace) + if s.value(SETTINGS_KEYS["created_at"], "") == "": s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - defaults |= set(read_only_sources.keys()) - users |= set(read_only_sources.keys()) + settings_names |= set(read_only_sources.keys()) # Return union of all discovered names - return sorted(defaults | users) + return sorted(settings_names) + + +def open_default_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the default profile settings file. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. -def open_default_settings(name: str) -> QSettings: - return QSettings(default_profile_path(name), QSettings.IniFormat) + Returns: + QSettings: Settings instance targeting the default profile file. + """ + return QSettings(default_profile_path(name, namespace), QSettings.IniFormat) -def open_user_settings(name: str) -> QSettings: - return QSettings(user_profile_path(name), QSettings.IniFormat) +def open_user_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the user profile settings file. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QSettings: Settings instance targeting the user profile file. + """ + return QSettings(user_profile_path(name, namespace), QSettings.IniFormat) def _app_settings() -> QSettings: - """Return app-wide settings file for AdvancedDockArea metadata.""" + """ + Access the application-wide metadata settings file for dock profiles. + + Returns: + QSettings: Handle to the ``_meta.ini`` metadata store under the profiles root. + """ return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) -def get_last_profile() -> str | None: - """Return the last-used profile name if stored, else None.""" +def _last_profile_key(namespace: str | None) -> str: + """ + Build the QSettings key used to store the last profile per namespace. + + Args: + namespace (str | None): Namespace label. + + Returns: + str: Scoped key string. + """ + ns = sanitize_namespace(namespace) + key = SETTINGS_KEYS["last_profile"] + return f"{key}/{ns}" if ns else key + + +def get_last_profile(namespace: str | None = None) -> str | None: + """ + Retrieve the last-used profile name persisted in app settings. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str | None: Profile name or ``None`` if none has been stored. + """ s = _app_settings() - name = s.value(SETTINGS_KEYS["last_profile"], "", type=str) + name = s.value(_last_profile_key(namespace), "", type=str) return name or None -def set_last_profile(name: str | None) -> None: - """Persist the last-used profile name (or clear it if None).""" +def set_last_profile(name: str | None, namespace: str | None = None) -> None: + """ + Persist the last-used profile name (or clear the value when ``None``). + + Args: + name (str | None): Profile name to store. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + """ s = _app_settings() if name: - s.setValue(SETTINGS_KEYS["last_profile"], name) + s.setValue(_last_profile_key(namespace), name) else: - s.remove(SETTINGS_KEYS["last_profile"]) + s.remove(_last_profile_key(namespace)) def now_iso_utc() -> str: + """ + Return the current UTC timestamp formatted in ISO 8601. + + Returns: + str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``). + """ return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: + """ + Write the manifest of dock widgets to settings. + + Args: + settings(QSettings): Settings object to write to. + docks(list[CDockWidget]): List of dock widgets to serialize. + """ settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks)) for i, dock in enumerate(docks): settings.setArrayIndex(i) @@ -283,6 +634,15 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: def read_manifest(settings: QSettings) -> list[dict]: + """ + Read the manifest of dock widgets from settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + list[dict]: List of dock widget metadata dictionaries. + """ items: list[dict] = [] count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) for i in range(count): @@ -300,47 +660,88 @@ def read_manifest(settings: QSettings) -> list[dict]: return items -def restore_user_from_default(name: str) -> None: - """Overwrite the user profile with the default baseline (keep default intact).""" - src = default_profile_path(name) - dst = user_profile_path(name) - if not os.path.exists(src): +def restore_user_from_default(name: str, namespace: str | None = None) -> None: + """ + Copy the default profile to the user profile, preserving quick-select flag. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + src = None + for candidate in default_profile_candidates(name, namespace): + if os.path.exists(candidate): + src = candidate + break + if not src: return - preserve_quick_select = is_quick_select(name) + dst = user_profile_path(name, namespace) + preserve_quick_select = is_quick_select(name, namespace) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copyfile(src, dst) - s = open_user_settings(name) + s = open_user_settings(name, namespace) if not s.value(SETTINGS_KEYS["created_at"], ""): s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) if preserve_quick_select: s.setValue(SETTINGS_KEYS["is_quick_select"], True) -def is_quick_select(name: str) -> bool: - """Return True if profile is marked to appear in quick-select combo.""" - s = ( - open_user_settings(name) - if os.path.exists(user_profile_path(name)) - else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None) - ) +def is_quick_select(name: str, namespace: str | None = None) -> bool: + """ + Return True if profile is marked to appear in quick-select combo. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + bool: True if quick-select is enabled for the profile. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) if s is None: return False return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) -def set_quick_select(name: str, enabled: bool) -> None: - """Set/unset the quick-select flag on the USER copy (creates it if missing).""" - s = open_user_settings(name) +def set_quick_select(name: str, enabled: bool, namespace: str | None = None) -> None: + """ + Set or clear the quick-select flag for a profile. + + Args: + name(str): Profile name without extension. + enabled(bool): True to enable quick-select, False to disable. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + s = open_user_settings(name, namespace) s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) -def list_quick_profiles() -> list[str]: - """List only profiles that have quick-select enabled (user wins over default).""" - names = list_profiles() - return [n for n in names if is_quick_select(n)] +def list_quick_profiles(namespace: str | None = None) -> list[str]: + """ + List only profiles that have quick-select enabled (user wins over default). + + Args: + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: Sorted list of profile names with quick-select enabled. + """ + names = list_profiles(namespace) + return [n for n in names if is_quick_select(n, namespace)] def _file_modified_iso(path: str) -> str: + """ + Get the file modification time as an ISO 8601 UTC string. + + Args: + path(str): Path to the file. + + Returns: + str: ISO 8601 UTC timestamp of last modification, or current time if unavailable. + """ try: mtime = os.path.getmtime(path) return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) @@ -349,12 +750,30 @@ def _file_modified_iso(path: str) -> str: def _manifest_count(settings: QSettings) -> int: + """ + Get the number of widgets recorded in the manifest. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + int: Number of widgets in the manifest. + """ n = settings.beginReadArray(SETTINGS_KEYS["manifest"]) settings.endArray() return int(n or 0) def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: + """ + Load the screenshot pixmap stored in the given settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ data = settings.value(SETTINGS_KEYS["screenshot"], None) if not data: return None @@ -379,6 +798,8 @@ def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: class ProfileInfo(BaseModel): + """Pydantic model capturing profile metadata surfaced in the UI.""" + name: str author: str = "BEC Widgets" notes: str = "" @@ -393,21 +814,30 @@ class ProfileInfo(BaseModel): is_read_only: bool = False -def get_profile_info(name: str) -> ProfileInfo: +def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo: """ - Return merged metadata for a profile as a validated Pydantic model. - Prefers the USER copy; falls back to DEFAULT if the user copy is missing. + Assemble metadata and statistics for a profile. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + ProfileInfo: Structured profile metadata, preferring the user copy when present. """ - u_path = user_profile_path(name) - d_path = default_profile_path(name) - origin = profile_origin(name) - prefer_user = os.path.exists(u_path) + user_paths = user_profile_candidates(name, namespace) + default_paths = default_profile_candidates(name, namespace) + u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0]) + d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0]) + origin = profile_origin(name, namespace) read_only = origin in {"module", "plugin"} - s = ( - open_user_settings(name) - if prefer_user - else (open_default_settings(name) if os.path.exists(d_path) else None) - ) + prefer_user = os.path.exists(u_path) + if prefer_user: + s = QSettings(u_path, QSettings.IniFormat) + elif os.path.exists(d_path): + s = QSettings(d_path, QSettings.IniFormat) + else: + s = None if s is None: if origin == "module": author = "BEC Widgets" @@ -456,7 +886,7 @@ def get_profile_info(name: str) -> ProfileInfo: notes=s.value("profile/notes", "", type=str) or "", created=created, modified=modified, - is_quick_select=is_quick_select(name), + is_quick_select=is_quick_select(name, namespace), widget_count=count, size_kb=size_kb, user_path=u_path, @@ -466,29 +896,54 @@ def get_profile_info(name: str) -> ProfileInfo: ) -def load_profile_screenshot(name: str) -> QPixmap | None: - """Load the stored screenshot pixmap for a profile from settings (user preferred).""" - u_path = user_profile_path(name) - d_path = default_profile_path(name) - s = ( - open_user_settings(name) - if os.path.exists(u_path) - else (open_default_settings(name) if os.path.exists(d_path) else None) - ) +def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the stored screenshot pixmap for a profile from settings (user preferred). + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) if s is None: return None return _load_screenshot_from_settings(s) -def load_user_profile_screenshot(name: str) -> QPixmap | None: - """Load the screenshot from the user profile copy, if available.""" - if not os.path.exists(user_profile_path(name)): +def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the default profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_default_settings(name, namespace) + if s is None: return None - return _load_screenshot_from_settings(open_user_settings(name)) + return _load_screenshot_from_settings(s) -def load_default_profile_screenshot(name: str) -> QPixmap | None: - """Load the screenshot from the default profile copy, if available.""" - if not os.path.exists(default_profile_path(name)): +def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the user profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: return None - return _load_screenshot_from_settings(open_default_settings(name)) + return _load_screenshot_from_settings(s) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py index e9d60f3f0..19329c34b 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -5,7 +5,6 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( - QApplication, QCheckBox, QDialog, QGroupBox, @@ -183,10 +182,10 @@ def accept(self): "Overwriting will update both the saved profile and its restore default.\n" "Do you want to continue?" ), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply != QMessageBox.Yes: + if reply != QMessageBox.StandardButton.Yes: suggestion = self._generate_unique_name(name) self._block_name_signals = True self.name_edit.setText(suggestion) @@ -206,16 +205,15 @@ class PreviewPanel(QGroupBox): def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None): super().__init__(title, parent) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None layout = QVBoxLayout(self) - # layout.setContentsMargins(0,0,0,0) # leave room for group title and frame self.image_label = QLabel() - self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setMinimumSize(360, 240) - self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.image_label, 1) if self._original: @@ -227,6 +225,13 @@ def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = ) def setPixmap(self, pixmap: QPixmap | None): + """ + Set the pixmap to display in the preview panel. + + Args: + pixmap(QPixmap | None): The pixmap to display. If None or null, clears the preview. + + """ self._original = pixmap if (pixmap and not pixmap.isNull()) else None if self._original: self.image_label.setText("") diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index 936227479..36357a17e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -2,6 +2,7 @@ from functools import partial +from bec_lib import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap @@ -11,8 +12,6 @@ QHBoxLayout, QHeaderView, QLabel, - QLineEdit, - QMainWindow, QMessageBox, QPushButton, QSizePolicy, @@ -28,7 +27,7 @@ ) from bec_widgets import BECWidget, SafeSlot -from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( delete_profile_files, get_profile_info, @@ -38,6 +37,8 @@ set_quick_select, ) +logger = bec_logger.logger + class WorkSpaceManager(BECWidget, QWidget): RPC = False @@ -52,6 +53,9 @@ def __init__( ): super().__init__(parent=parent, **kwargs) self.target_widget = target_widget + self.profile_namespace = ( + getattr(target_widget, "profile_namespace", None) if target_widget else None + ) self.accent_colors = get_accent_colors() self._init_ui() if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): @@ -144,7 +148,7 @@ def _init_screenshot_preview(self): def render_table(self): self.profile_table.setRowCount(0) - for profile in list_profiles(): + for profile in list_profiles(namespace=self.profile_namespace): self._add_profile_row(profile) def _add_profile_row(self, name: str): @@ -156,7 +160,7 @@ def _add_profile_row(self, name: str): actions_items_layout = QHBoxLayout(actions_items) actions_items_layout.setContentsMargins(0, 0, 0, 0) - info = get_profile_info(name) + info = get_profile_info(name, namespace=self.profile_namespace) # Flags is_active = ( @@ -237,7 +241,7 @@ def _current_selected_profile(self) -> str | None: return item.text() if item else None def _show_profile_details(self, name: str) -> None: - info = get_profile_info(name) + info = get_profile_info(name, namespace=self.profile_namespace) self.profile_details_tree.clear() entries = [ ("Name", info.name), @@ -255,7 +259,7 @@ def _show_profile_details(self, name: str) -> None: self.profile_details_tree.expandAll() # Render screenshot preview from profile INI - pm = load_profile_screenshot(name) + pm = load_profile_screenshot(name, namespace=self.profile_namespace) if pm is not None and not pm.isNull(): scaled = pm.scaled( self.screenshot_label.width() or 800, @@ -299,16 +303,16 @@ def switch_profile(self, profile_name: str): "workspace_combo" ).widget.setCurrentText(profile_name) except Exception as e: - print(f"Warning: Could not update workspace combo box. {e}") - pass + logger.warning(f"Warning: Could not update workspace combo box. {e}") + self.render_table() self._select_by_name(profile_name) self._show_profile_details(profile_name) @SafeSlot(str) def toggle_quick_select(self, profile_name: str): - enabled = is_quick_select(profile_name) - set_quick_select(profile_name, not enabled) + enabled = is_quick_select(profile_name, namespace=self.profile_namespace) + set_quick_select(profile_name, not enabled, namespace=self.profile_namespace) self.render_table() if self.target_widget is not None: self.target_widget._refresh_workspace_list() @@ -337,7 +341,7 @@ def save_current_as_profile(self): @SafeSlot(str) def delete_profile(self, profile_name: str): - info = get_profile_info(profile_name) + info = get_profile_info(profile_name, namespace=self.profile_namespace) if info.is_read_only: QMessageBox.information( self, "Delete Profile", "This profile is read-only and cannot be deleted." @@ -358,7 +362,7 @@ def delete_profile(self, profile_name: str): return try: - removed = delete_profile_files(profile_name) + removed = delete_profile_files(profile_name, namespace=self.profile_namespace) except OSError as exc: QMessageBox.warning( self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" @@ -378,7 +382,7 @@ def delete_profile(self, profile_name: str): self.target_widget._refresh_workspace_list() self.render_table() - remaining_profiles = list_profiles() + remaining_profiles = list_profiles(namespace=self.profile_namespace) if remaining_profiles: next_profile = remaining_profiles[0] self._select_by_name(next_profile) @@ -392,7 +396,7 @@ def resizeEvent(self, event): name = self._current_selected_profile() if not name: return - pm = load_profile_screenshot(name) + pm = load_profile_screenshot(name, namespace=self.profile_namespace) if pm is None or pm.isNull(): return scaled = pm.scaled( diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index bdfb9a5a2..cb7dabea1 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Callable + from qtpy.QtCore import Qt from qtpy.QtGui import QFont from qtpy.QtWidgets import QComboBox, QSizePolicy @@ -17,6 +19,10 @@ class ProfileComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._quick_provider: Callable[[], list[str]] = list_quick_profiles + + def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: + self._quick_provider = provider def refresh_profiles(self, active_profile: str | None = None): """ @@ -30,7 +36,7 @@ def refresh_profiles(self, active_profile: str | None = None): self.blockSignals(True) self.clear() - quick_profiles = list_quick_profiles() + quick_profiles = self._quick_provider() quick_set = set(quick_profiles) items = list(quick_profiles) @@ -76,7 +82,7 @@ def refresh_profiles(self, active_profile: str | None = None): self.setToolTip("") -def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: +def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: """ Creates a workspace toolbar bundle for AdvancedDockArea. @@ -88,9 +94,9 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: """ # Workspace combo combo = ProfileComboBox(parent=components.toolbar) + combo.setVisible(enable_tools) components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) - # Save the current workspace icon components.add_safe( "save_workspace", MaterialIconAction( @@ -100,7 +106,8 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: parent=components.toolbar, ), ) - # Delete workspace icon + components.get_action("save_workspace").action.setVisible(enable_tools) + components.add_safe( "reset_default_workspace", MaterialIconAction( @@ -110,17 +117,15 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: parent=components.toolbar, ), ) - # Workspace Manager icon + components.get_action("reset_default_workspace").action.setVisible(enable_tools) + components.add_safe( "manage_workspaces", MaterialIconAction( - icon_name="manage_accounts", - tooltip="Manage", - checkable=True, - parent=components.toolbar, - label_text="Manage", + icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar ), ) + components.get_action("manage_workspaces").action.setVisible(enable_tools) bundle = ToolbarBundle("workspace", components) bundle.add_action("workspace_combo") @@ -147,35 +152,40 @@ def __init__(self, components: ToolbarComponents, target_widget=None): def connect(self): self._connected = True # Connect the action to the target widget's method - self.components.get_action("save_workspace").action.triggered.connect( - self.target_widget.save_profile - ) + save_action = self.components.get_action("save_workspace").action + if save_action.isVisible(): + save_action.triggered.connect(self.target_widget.save_profile) + self.components.get_action("workspace_combo").widget.currentTextChanged.connect( self.target_widget.load_profile ) - self.components.get_action("reset_default_workspace").action.triggered.connect( - self._reset_workspace_to_default - ) - self.components.get_action("manage_workspaces").action.triggered.connect( - self.target_widget.show_workspace_manager - ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.connect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.connect(self.target_widget.show_workspace_manager) def disconnect(self): if not self._connected: return # Disconnect the action from the target widget's method - self.components.get_action("save_workspace").action.triggered.disconnect( - self.target_widget.save_profile - ) + save_action = self.components.get_action("save_workspace").action + if save_action.isVisible(): + save_action.triggered.disconnect(self.target_widget.save_profile) self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( self.target_widget.load_profile ) - self.components.get_action("reset_default_workspace").action.triggered.disconnect( - self._reset_workspace_to_default - ) - self.components.get_action("manage_workspaces").action.triggered.disconnect( - self.target_widget.show_workspace_manager - ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.disconnect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.disconnect(self.target_widget.show_workspace_manager) self._connected = False @SafeSlot() diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 6dc5fa2c6..a5990e6db 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -2,7 +2,6 @@ import os -import shiboken6 from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEvent, QSize, Qt, QTimer from qtpy.QtGui import QAction, QActionGroup, QIcon @@ -22,7 +21,6 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 6dfe76e59..212494b2f 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -6,16 +6,20 @@ from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings, Qt +from qtpy.QtCore import QSettings, Qt, QTimer from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QDialog, QMessageBox +from qtpy.QtWidgets import QDialog, QMessageBox, QWidget +import bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area as basic_dock_module import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( AdvancedDockArea, - DockSettingsDialog, SaveProfileDialog, ) +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import ( + DockAreaWidget, + DockSettingsDialog, +) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( default_profile_path, get_profile_info, @@ -145,13 +149,372 @@ def _factory(): return _factory +@pytest.fixture +def basic_dock_area(qtbot, mocked_client): + """Create a namesake DockAreaWidget without the advanced toolbar.""" + widget = DockAreaWidget(client=mocked_client, title="Test Dock Area") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class _NamespaceProfiles: + """Helper that routes profile file helpers through a namespace.""" + + def __init__(self, widget: AdvancedDockArea): + self.namespace = widget.profile_namespace + + def open_user(self, name: str): + return open_user_settings(name, namespace=self.namespace) + + def open_default(self, name: str): + return open_default_settings(name, namespace=self.namespace) + + def user_path(self, name: str) -> str: + return user_profile_path(name, namespace=self.namespace) + + def default_path(self, name: str) -> str: + return default_profile_path(name, namespace=self.namespace) + + def list_profiles(self) -> list[str]: + return list_profiles(namespace=self.namespace) + + def set_quick_select(self, name: str, enabled: bool): + set_quick_select(name, enabled, namespace=self.namespace) + + def is_quick_select(self, name: str) -> bool: + return is_quick_select(name, namespace=self.namespace) + + +def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles: + """Return a helper wired to the widget's profile namespace.""" + return _NamespaceProfiles(widget) + + +class TestBasicDockArea: + """Focused coverage for the lightweight DockAreaWidget base.""" + + def test_new_widget_instance_registers_in_maps(self, basic_dock_area): + panel = QWidget(parent=basic_dock_area) + panel.setObjectName("basic_panel") + + dock = basic_dock_area.new(panel, return_dock=True) + + assert dock.objectName() == "basic_panel" + assert basic_dock_area.dock_map()["basic_panel"] is dock + assert basic_dock_area.widget_map()["basic_panel"] is panel + + def test_new_widget_string_creates_widget(self, basic_dock_area, qtbot): + basic_dock_area.new("DarkModeButton") + qtbot.waitUntil(lambda: len(basic_dock_area.dock_list()) > 0, timeout=1000) + + assert basic_dock_area.widget_list() + + def test_custom_close_handler_invoked(self, basic_dock_area, qtbot): + class CloseAwareWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("closable") + self.closed = False + + def handle_dock_close(self, dock, widget): # pragma: no cover - exercised via signal + self.closed = True + dock.closeDockWidget() + dock.deleteDockWidget() + + widget = CloseAwareWidget(parent=basic_dock_area) + dock = basic_dock_area.new(widget, return_dock=True) + + dock.closeRequested.emit() + qtbot.waitUntil(lambda: widget.closed, timeout=1000) + + assert widget.closed is True + assert "closable" not in basic_dock_area.dock_map() + + def test_attach_all_and_delete_all(self, basic_dock_area): + first = QWidget(parent=basic_dock_area) + first.setObjectName("floating_one") + second = QWidget(parent=basic_dock_area) + second.setObjectName("floating_two") + + dock_one = basic_dock_area.new(first, return_dock=True, start_floating=True) + dock_two = basic_dock_area.new(second, return_dock=True, start_floating=True) + assert dock_one.isFloating() and dock_two.isFloating() + + basic_dock_area.attach_all() + + assert not dock_one.isFloating() + assert not dock_two.isFloating() + + basic_dock_area.delete_all() + assert basic_dock_area.dock_list() == [] + + def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area): + weights = {"default": 0.5, "left": 2, "center": 3, "right": 4} + + result = basic_dock_area._coerce_weights(weights, 3, Qt.Orientation.Horizontal) + + assert result == [2.0, 3.0, 4.0] + assert basic_dock_area._coerce_weights([0.0], 3, Qt.Orientation.Vertical) == [0.0, 1.0, 1.0] + assert basic_dock_area._coerce_weights([0.0, 0.0], 2, Qt.Orientation.Vertical) == [1.0, 1.0] + + def test_splitter_override_keys_are_normalized(self, basic_dock_area): + overrides = {0: [1, 2], (1, 0): [3, 4], "2.1": [5], " / ": [6]} + + normalized = basic_dock_area._normalize_override_keys(overrides) + + assert normalized == {(0,): [1, 2], (1, 0): [3, 4], (2, 1): [5], (): [6]} + + def test_schedule_splitter_weights_sets_sizes(self, basic_dock_area, monkeypatch): + monkeypatch.setattr(QTimer, "singleShot", lambda *_args: _args[-1]()) + + class DummySplitter: + def __init__(self): + self._children = [object(), object(), object()] + self.sizes = None + self.stretch = [] + + def count(self): + return len(self._children) + + def orientation(self): + return Qt.Orientation.Horizontal + + def width(self): + return 300 + + def height(self): + return 120 + + def setSizes(self, sizes): + self.sizes = sizes + + def setStretchFactor(self, idx, value): + self.stretch.append((idx, value)) + + splitter = DummySplitter() + + basic_dock_area._schedule_splitter_weights(splitter, [1, 2, 1]) + + assert splitter.sizes == [75, 150, 75] + assert splitter.stretch == [(0, 100), (1, 200), (2, 100)] + + def test_apply_splitter_tree_honors_overrides(self, basic_dock_area, monkeypatch): + class DummySplitter: + def __init__(self, orientation, children=None, label="splitter"): + self._orientation = orientation + self._children = list(children or []) + self.label = label + + def count(self): + return len(self._children) + + def orientation(self): + return self._orientation + + def widget(self, idx): + return self._children[idx] + + monkeypatch.setattr(basic_dock_module.QtAds, "CDockSplitter", DummySplitter) + + leaf = DummySplitter(Qt.Orientation.Horizontal, [], label="leaf") + column_one = DummySplitter(Qt.Orientation.Vertical, [leaf], label="column_one") + column_zero = DummySplitter(Qt.Orientation.Vertical, [], label="column_zero") + root = DummySplitter(Qt.Orientation.Horizontal, [column_zero, column_one], label="root") + + calls = [] + + def fake_schedule(self, splitter, weights): + calls.append((splitter.label, weights)) + + monkeypatch.setattr(DockAreaWidget, "_schedule_splitter_weights", fake_schedule) + + overrides = {(): ["root_override"], (0,): ["column_override"]} + + basic_dock_area._apply_splitter_tree( + root, (), horizontal=[1, 2], vertical=[3, 4], overrides=overrides + ) + + assert calls[0] == ("root", ["root_override"]) + assert calls[1] == ("column_zero", ["column_override"]) + assert calls[2] == ("column_one", [3, 4]) + assert calls[3] == ("leaf", ["column_override"]) + + def test_set_layout_ratios_normalizes_and_applies(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + root_one = object() + root_two = object() + containers = [DummyContainer(root_one), DummyContainer(None), DummyContainer(root_two)] + + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def fake_apply(self, splitter, path, horizontal, vertical, overrides): + calls.append((splitter, path, horizontal, vertical, overrides)) + + monkeypatch.setattr(DockAreaWidget, "_apply_splitter_tree", fake_apply) + + basic_dock_area.set_layout_ratios( + horizontal=[1, 1, 1], vertical=[2, 3], splitter_overrides={"1/0": [5, 5], "": [9]} + ) + + assert len(calls) == 2 + for splitter, path, horizontal, vertical, overrides in calls: + assert splitter in {root_one, root_two} + assert path == () + assert horizontal == [1, 1, 1] + assert vertical == [2, 3] + assert overrides == {(): [9], (1, 0): [5, 5]} + + def test_show_settings_action_defaults_disabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_default") + + dock = basic_dock_area.new(widget, return_dock=True) + + assert dock._dock_preferences.get("show_settings_action") is False + assert not hasattr(dock, "setting_action") + + def test_show_settings_action_can_be_enabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_enabled") + + dock = basic_dock_area.new(widget, return_dock=True, show_settings_action=True) + + assert dock._dock_preferences.get("show_settings_action") is True + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + + def test_collect_splitter_info_describes_children(self, basic_dock_area, monkeypatch): + class DummyDockWidget: + def __init__(self, name): + self._name = name + + def objectName(self): + return self._name + + class DummyDockArea: + def __init__(self, dock_names): + self._docks = [DummyDockWidget(name) for name in dock_names] + + def dockWidgets(self): + return self._docks + + class DummySplitter: + def __init__(self, orientation, children=None): + self._orientation = orientation + self._children = list(children or []) + + def orientation(self): + return self._orientation + + def count(self): + return len(self._children) + + def widget(self, idx): + return self._children[idx] + + class Spacer: + pass + + monkeypatch.setattr(basic_dock_module, "CDockSplitter", DummySplitter) + monkeypatch.setattr(basic_dock_module, "CDockAreaWidget", DummyDockArea) + monkeypatch.setattr(basic_dock_module, "CDockWidget", DummyDockWidget) + + nested_splitter = DummySplitter(Qt.Orientation.Horizontal) + dock_area_child = DummyDockArea(["left", "right"]) + dock_child = DummyDockWidget("solo") + spacer = Spacer() + root_splitter = DummySplitter( + Qt.Orientation.Vertical, [nested_splitter, dock_area_child, dock_child, spacer] + ) + + results = [] + + basic_dock_area._collect_splitter_info(root_splitter, (2,), results, container_index=5) + + assert len(results) == 2 + root_entry = results[0] + assert root_entry["container"] == 5 + assert root_entry["path"] == (2,) + assert root_entry["orientation"] == "vertical" + assert root_entry["children"] == [ + {"index": 0, "type": "splitter"}, + {"index": 1, "type": "dock_area", "docks": ["left", "right"]}, + {"index": 2, "type": "dock", "name": "solo"}, + {"index": 3, "type": "Spacer"}, + ] + nested_entry = results[1] + assert nested_entry["path"] == (2, 0) + assert nested_entry["orientation"] == "horizontal" + + def test_describe_layout_aggregates_containers(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + containers = [DummyContainer("root0"), DummyContainer(None), DummyContainer("root2")] + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def recorder(self, splitter, path, results, container_index): + entry = {"container": container_index, "splitter": splitter, "path": path} + results.append(entry) + calls.append(entry) + + monkeypatch.setattr(DockAreaWidget, "_collect_splitter_info", recorder) + + info = basic_dock_area.describe_layout() + + assert info == calls + assert [entry["splitter"] for entry in info] == ["root0", "root2"] + assert [entry["container"] for entry in info] == [0, 2] + assert all(entry["path"] == () for entry in info) + + def test_print_layout_structure_formats_output(self, basic_dock_area, monkeypatch, capsys): + entries = [ + { + "container": 1, + "path": (0,), + "orientation": "horizontal", + "children": [ + {"index": 0, "type": "dock_area", "docks": ["alpha", "beta"]}, + {"index": 1, "type": "dock", "name": "solo"}, + {"index": 2, "type": "splitter"}, + {"index": 3, "type": "Placeholder"}, + ], + } + ] + + monkeypatch.setattr(DockAreaWidget, "describe_layout", lambda self: entries) + + basic_dock_area.print_layout_structure() + + captured = capsys.readouterr().out.strip().splitlines() + assert captured == [ + "container=1 path=(0,) orientation=horizontal -> " + "[0:dock_area[alpha, beta], 1:dock(solo), 2:splitter, 3:Placeholder]" + ] + + class TestAdvancedDockAreaInit: """Test initialization and basic properties.""" def test_init(self, advanced_dock_area): assert advanced_dock_area is not None assert isinstance(advanced_dock_area, AdvancedDockArea) - assert advanced_dock_area.mode == "developer" + assert advanced_dock_area.mode == "creator" assert hasattr(advanced_dock_area, "dock_manager") assert hasattr(advanced_dock_area, "toolbar") assert hasattr(advanced_dock_area, "dark_mode_button") @@ -293,6 +656,29 @@ def test_delete_all(self, advanced_dock_area, qtbot): assert len(advanced_dock_area.dock_list()) == 0 +class TestAdvancedDockSettingsAction: + """Ensure AdvancedDockArea exposes dock settings actions by default.""" + + def test_settings_action_installed_by_default(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_default_settings") + + dock = advanced_dock_area.new(widget, return_dock=True) + + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + assert dock._dock_preferences.get("show_settings_action") is True + + def test_settings_action_can_be_disabled(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_settings_off") + + dock = advanced_dock_area.new(widget, return_dock=True, show_settings_action=False) + + assert not hasattr(dock, "setting_action") + assert dock._dock_preferences.get("show_settings_action") is False + + class TestWorkspaceLocking: """Test workspace locking functionality.""" @@ -873,6 +1259,11 @@ def test_delete_readonly_profile_shows_message( ): readonly = module_profile_factory("readonly_delete") list_profiles() + monkeypatch.setattr( + profile_utils, + "get_profile_info", + lambda *a, **k: profile_utils.ProfileInfo(name=readonly, is_read_only=True), + ) info_calls = [] monkeypatch.setattr( QMessageBox, @@ -892,19 +1283,20 @@ class TestAdvancedDockAreaRestoreAndDialogs: def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): profile_name = "profile_restore_true" - open_default_settings(profile_name).sync() - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False pix = QPixmap(8, 8) pix.fill(Qt.red) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", - lambda name: pix, + lambda name, namespace=None: pix, ) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", - lambda name: pix, + lambda name, namespace=None: pix, ) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", @@ -920,14 +1312,18 @@ def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area ): advanced_dock_area.restore_user_profile_from_default() - mock_restore.assert_called_once_with(profile_name) + assert mock_restore.call_count == 1 + args, kwargs = mock_restore.call_args + assert args == (profile_name,) + assert kwargs.get("namespace") == advanced_dock_area.profile_namespace mock_delete_all.assert_called_once() mock_load_profile.assert_called_once_with(profile_name) def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): profile_name = "profile_restore_false" - open_default_settings(profile_name).sync() - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False monkeypatch.setattr( @@ -960,7 +1356,8 @@ def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, m def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): profile_name = "refresh_profile" - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles = MagicMock() @@ -1002,9 +1399,10 @@ def setToolTip(self, text): active = "active_profile" quick = "quick_profile" - open_user_settings(active).sync() - open_user_settings(quick).sync() - set_quick_select(quick, True) + helper = profile_helper(advanced_dock_area) + helper.open_user(active).sync() + helper.open_user(quick).sync() + helper.set_quick_select(quick, True) combo_stub = ComboStub() @@ -1027,7 +1425,8 @@ def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): assert not action.isChecked() advanced_dock_area._current_profile_name = "manager_profile" - open_user_settings("manager_profile").sync() + helper = profile_helper(advanced_dock_area) + helper.open_user("manager_profile").sync() advanced_dock_area.show_workspace_manager() @@ -1175,7 +1574,8 @@ def test_save_profile_readonly_conflict( """Test saving profile when read-only profile exists.""" profile_name = module_profile_factory("readonly_profile") new_profile = f"{profile_name}_custom" - target_path = user_profile_path(new_profile) + helper = profile_helper(advanced_dock_area) + target_path = helper.user_path(new_profile) if os.path.exists(target_path): os.remove(target_path) @@ -1203,9 +1603,10 @@ def is_quick_select(self): def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): """Test loading profile with widget manifest.""" profile_name = "test_load_profile" + helper = profile_helper(advanced_dock_area) # Create a profile with manifest - settings = open_user_settings(profile_name) + settings = helper.open_user(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -1232,8 +1633,9 @@ def test_save_as_skips_autosave_source_profile( """Saving a new profile avoids overwriting the source profile during the switch.""" source_profile = "autosave_source" new_profile = "autosave_new" + helper = profile_helper(advanced_dock_area) - settings = open_user_settings(source_profile) + settings = helper.open_user(source_profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "source_widget") @@ -1269,8 +1671,8 @@ def is_quick_select(self): advanced_dock_area.save_profile() qtbot.wait(500) - source_manifest = read_manifest(open_user_settings(source_profile)) - new_manifest = read_manifest(open_user_settings(new_profile)) + source_manifest = read_manifest(helper.open_user(source_profile)) + new_manifest = read_manifest(helper.open_user(new_profile)) assert len(source_manifest) == 1 assert len(new_manifest) == 2 @@ -1279,9 +1681,10 @@ def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profil """Regular profile switches should persist the outgoing layout.""" profile_a = "autosave_keep" profile_b = "autosave_target" + helper = profile_helper(advanced_dock_area) for profile in (profile_a, profile_b): - settings = open_user_settings(profile) + settings = helper.open_user(profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", f"{profile}_widget") @@ -1300,7 +1703,7 @@ def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profil advanced_dock_area.load_profile(profile_b) qtbot.wait(500) - manifest_a = read_manifest(open_user_settings(profile_a)) + manifest_a = read_manifest(helper.open_user(profile_a)) assert len(manifest_a) == 2 def test_delete_profile_readonly( @@ -1308,12 +1711,14 @@ def test_delete_profile_readonly( ): """Test deleting bundled profile removes only the writable copy.""" profile_name = module_profile_factory("readonly_profile") - list_profiles() # ensure default and user copies are materialized - settings = open_user_settings(profile_name) + helper = profile_helper(advanced_dock_area) + helper.list_profiles() # ensure default and user copies are materialized + helper.open_default(profile_name).sync() + settings = helper.open_user(profile_name) settings.setValue("test", "value") settings.sync() - user_path = user_profile_path(profile_name) - default_path = default_profile_path(profile_name) + user_path = helper.user_path(profile_name) + default_path = helper.default_path(profile_name) assert os.path.exists(user_path) assert os.path.exists(default_path) @@ -1322,27 +1727,34 @@ def test_delete_profile_readonly( mock_combo.currentText.return_value = profile_name mock_get_action.return_value.widget = mock_combo - with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" - ) as mock_question: - mock_question.return_value = QMessageBox.Yes - + with ( + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question", + return_value=QMessageBox.Yes, + ) as mock_question, + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information", + return_value=None, + ) as mock_info, + ): advanced_dock_area.delete_profile() - mock_question.assert_called_once() - # User copy should be removed, default remains - assert not os.path.exists(user_path) + mock_question.assert_not_called() + mock_info.assert_called_once() + # Read-only profile should remain intact (user + default copies) + assert os.path.exists(user_path) assert os.path.exists(default_path) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" profile_name = "deletable_profile" + helper = profile_helper(advanced_dock_area) # Create regular profile - settings = open_user_settings(profile_name) + settings = helper.open_user(profile_name) settings.setValue("test", "value") settings.sync() - user_path = user_profile_path(profile_name) + user_path = helper.user_path(profile_name) assert os.path.exists(user_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: @@ -1366,8 +1778,9 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles + helper = profile_helper(advanced_dock_area) for name in ["profile1", "profile2"]: - settings = open_user_settings(name) + settings = helper.open_user(name) settings.setValue("test", "value") settings.sync() @@ -1451,15 +1864,18 @@ def test_install_dock_settings_action(self, advanced_dock_area): widget = DarkModeButton(parent=advanced_dock_area) widget.setObjectName("test_widget") - dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + with patch.object(advanced_dock_area, "_open_dock_settings_dialog") as mock_open_dialog: + dock = advanced_dock_area._make_dock( + widget, closable=True, floatable=True, movable=True + ) - # Verify dock has settings action - assert hasattr(dock, "setting_action") - assert dock.setting_action is not None + # Verify dock has settings action + assert hasattr(dock, "setting_action") + assert dock.setting_action is not None + assert dock.setting_action.toolTip() == "Dock settings" - # Verify title bar actions were set - title_bar_actions = dock.titleBarActions() - assert len(title_bar_actions) >= 1 + dock.setting_action.trigger() + mock_open_dialog.assert_called_once_with(dock, widget) class TestModeSwitching: @@ -1467,7 +1883,7 @@ class TestModeSwitching: def test_mode_property_setter_valid_modes(self, advanced_dock_area): """Test setting valid modes.""" - valid_modes = ["plot", "device", "utils", "developer", "user"] + valid_modes = ["plot", "device", "utils", "creator", "user"] for mode in valid_modes: advanced_dock_area.mode = mode @@ -1534,7 +1950,7 @@ def test_utils_mode_toolbar_visibility(self, advanced_dock_area): def test_developer_mode_toolbar_visibility(self, advanced_dock_area): """Test toolbar bundle visibility in developer mode.""" - advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "creator" shown_bundles = advanced_dock_area.toolbar.shown_bundles @@ -1622,7 +2038,7 @@ def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): with patch.object(advanced_dock_area, "new") as mock_new: action = advanced_dock_area.toolbar.components.get_action(action_name).action action.trigger() - mock_new.assert_called_once_with(widget=widget_type) + mock_new.assert_called_once_with(widget_type) def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): """Test flat device actions trigger widget creation.""" @@ -1635,7 +2051,7 @@ def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): with patch.object(advanced_dock_area, "new") as mock_new: action = advanced_dock_area.toolbar.components.get_action(action_name).action action.trigger() - mock_new.assert_called_once_with(widget=widget_type) + mock_new.assert_called_once_with(widget_type) def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): """Test flat utils actions trigger widget creation.""" @@ -1658,7 +2074,7 @@ def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): continue action.trigger() - mock_new.assert_called_once_with(widget=widget_type) + mock_new.assert_called_once_with(widget_type) def test_flat_log_panel_action_disabled(self, advanced_dock_area): """Test that flat log panel action is disabled.""" @@ -1671,7 +2087,7 @@ class TestModeTransitions: def test_mode_transition_sequence(self, advanced_dock_area, qtbot): """Test sequence of mode transitions.""" - modes = ["plot", "device", "utils", "developer", "user"] + modes = ["plot", "device", "utils", "creator", "user"] for mode in modes: with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: @@ -1686,7 +2102,7 @@ def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): advanced_dock_area.mode = "plot" advanced_dock_area.mode = "device" advanced_dock_area.mode = "utils" - advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "creator" advanced_dock_area.mode = "user" # Final state should be consistent @@ -1758,7 +2174,7 @@ def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): def test_multiple_mode_changes(self, advanced_dock_area, qtbot): """Test multiple rapid mode changes.""" - modes = ["plot", "device", "utils", "developer", "user"] + modes = ["plot", "device", "utils", "creator", "user"] for i, mode in enumerate(modes): with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: From 73f474c7e7f3492e580ec7ecad9bae7a0ce0d4d2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 5 Nov 2025 18:28:24 +0100 Subject: [PATCH 033/155] refactor(monaco_dock): changed to use DockAreaWidget --- .../widgets/editors/monaco/monaco_dock.py | 119 ++++++++++-------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 25e8392a7..8b8cd1912 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -7,17 +7,16 @@ from bec_lib.logger import bec_logger from bec_lib.macro_update_handler import has_executable_code from qtpy.QtCore import QEvent, QTimer, Signal -from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget -import bec_widgets.widgets.containers.ads as QtAds -from bec_widgets import BECWidget from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger -class MonacoDock(BECWidget, QWidget): +class MonacoDock(DockAreaWidget): """ MonacoDock is a dock widget that contains Monaco editor instances. It is used to manage multiple Monaco editors in a dockable interface. @@ -29,55 +28,34 @@ class MonacoDock(BECWidget, QWidget): macro_file_updated = Signal(str) # Emitted when a macro file is saved def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - - self.dock_manager = QtAds.CDockManager(self) - self.dock_manager.setStyleSheet("") + super().__init__( + parent=parent, + variant="compact", + title="Monaco Editors", + default_add_direction="top", + **kwargs, + ) self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) - self._root_layout.addWidget(self.dock_manager) self.dock_manager.installEventFilter(self) self._last_focused_editor: CDockWidget | None = None self.focused_editor.connect(self._on_last_focused_editor_changed) - self.add_editor() - self._open_files = {} + initial_editor = self.add_editor() + if isinstance(initial_editor, CDockWidget): + self.last_focused_editor = initial_editor - def _create_editor(self): + def _create_editor_widget(self) -> MonacoWidget: + """Create a configured Monaco editor widget.""" init_lsp = len(self.dock_manager.dockWidgets()) == 0 widget = MonacoWidget(self, init_lsp=init_lsp) widget.save_enabled.connect(self.save_enabled.emit) widget.editor.signature_help_triggered.connect(self._on_signature_change) - count = len(self.dock_manager.dockWidgets()) - dock = CDockWidget(f"Untitled_{count + 1}") - dock.setWidget(widget) - - # Connect to modification status changes to update tab titles - widget.save_enabled.connect( - lambda modified: self._update_tab_title_for_modification(dock, modified) - ) - - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) - dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True) - - dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) - - return dock + return widget @property def last_focused_editor(self) -> CDockWidget | None: """ Get the last focused editor. """ - dock_widget = self.dock_manager.focusedDockWidget() - if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget): - self.last_focused_editor = dock_widget - return self._last_focused_editor @last_focused_editor.setter @@ -221,27 +199,55 @@ def eventFilter(self, obj, event): def add_editor( self, area: Any | None = None, title: str | None = None, tooltip: str | None = None - ): # Any as qt ads does not return a proper type + ) -> CDockWidget: """ - Adds a new Monaco editor dock widget to the dock manager. + Add a new Monaco editor dock to the specified area. + + Args: + area(Any | None): The area to add the editor to. If None, adds to the main area. + title(str | None): The title of the editor tab. If None, a default title is used. + tooltip(str | None): The tooltip for the editor tab. If None, no tooltip is set. + + Returns: + CDockWidget: The created dock widget containing the Monaco editor. """ - new_dock = self._create_editor() - if title is not None: - new_dock.setWindowTitle(title) + widget = self._create_editor_widget() + existing_count = len(self.dock_manager.dockWidgets()) + default_title = title or f"Untitled_{existing_count + 1}" + + tab_target: CDockWidget | None = None + if isinstance(area, CDockAreaWidget): + tab_target = area.currentDockWidget() + if tab_target is None: + docks = area.dockWidgets() + tab_target = docks[0] if docks else None + + dock = self.new( + widget, + closable=True, + floatable=False, + movable=True, + tab_with=tab_target, + return_dock=True, + on_close=self._on_editor_close_requested, + title_buttons={"float": False}, + where="right", + ) + dock.setWindowTitle(default_title) if tooltip is not None: - new_dock.setTabToolTip(tooltip) - if area is None: - area_obj = self.dock_manager.addDockWidgetTab( - QtAds.DockWidgetArea.TopDockWidgetArea, new_dock - ) - self._ensure_area_plus(area_obj) - else: - # If an area is provided, add the dock to that area - self.dock_manager.addDockWidgetTabToArea(new_dock, area) - self._ensure_area_plus(area) + dock.setTabToolTip(tooltip) + + widget.save_enabled.connect( + lambda modified, target=dock: self._update_tab_title_for_modification(target, modified) + ) + + area_widget = dock.dockAreaWidget() + if area_widget is not None: + self._ensure_area_plus(area_widget) QTimer.singleShot(0, self._scan_and_fix_areas) - return new_dock + self.last_focused_editor = dock + return dock def open_file(self, file_name: str, scope: str | None = None) -> None: """ @@ -252,6 +258,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: dock = self._get_editor_dock(file_name) if dock is not None: dock.setAsCurrentTab() + self.last_focused_editor = dock return file = os.path.basename(file_name) @@ -276,6 +283,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: editor_widget.open_file(file_name) if scope is not None: editor_widget.metadata["scope"] = scope + self.last_focused_editor = editor_dock return # File is not open, create a new editor @@ -285,6 +293,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: if scope is not None: widget.metadata["scope"] = scope editor_dock.setAsCurrentTab() + self.last_focused_editor = editor_dock def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -415,7 +424,7 @@ def _get_open_files(self) -> list[str]: open_files.append(editor_widget.current_file) return open_files - def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None: + def _get_editor_dock(self, file_name: str) -> CDockWidget | None: for widget in self.dock_manager.dockWidgets(): editor_widget = cast(MonacoWidget, widget.widget()) if editor_widget.current_file == file_name: From 3f46f7eb7e488e9bfeec0957513b7f2e0f4e39ae Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 5 Nov 2025 18:28:39 +0100 Subject: [PATCH 034/155] refactor(developer_view): changed to use DockAreaWidget --- .../views/developer_view/developer_view.py | 3 - .../views/developer_view/developer_widget.py | 184 +++++++++++------- 2 files changed, 110 insertions(+), 77 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py index 3b28a3920..6f177c752 100644 --- a/bec_widgets/applications/views/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -21,9 +21,6 @@ def __init__( self.developer_widget = DeveloperWidget(parent=self) self.set_content(self.developer_widget) - # Apply stretch after the layout is done - self.set_default_view([2, 5, 3], [7, 3]) - if __name__ == "__main__": import sys diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 9e16bceb7..21f9d1854 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -5,18 +5,16 @@ from bec_lib.script_executor import upload_script from bec_qthemes import material_icon from qtpy.QtGui import QKeySequence, QShortcut -from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget -from shiboken6 import isValid +from qtpy.QtWidgets import QTextEdit -import bec_widgets.widgets.containers.ads as QtAds -from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.ads import CDockManager, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -77,31 +75,38 @@ def replace_python_examples(match): return css + html -class DeveloperWidget(BECWidget, QWidget): +class DeveloperWidget(DockAreaWidget): def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + super().__init__(parent=parent, variant="compact", **kwargs) - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) + # Promote toolbar above the dock manager provided by the base class self.toolbar = ModularToolBar(self) self.init_developer_toolbar() - self._root_layout.addWidget(self.toolbar) - - self.dock_manager = CDockManager(self) - self.dock_manager.setStyleSheet("") - self._root_layout.addWidget(self.dock_manager) + self._root_layout.insertWidget(0, self.toolbar) # Initialize the widgets self.explorer = IDEExplorer(self) + self.explorer.setObjectName("Explorer") self.console = WebConsole(self) + self.console.setObjectName("Console") self.terminal = WebConsole(self, startup_cmd="") + self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) + self.monaco.setObjectName("Monaco Editor") self.monaco.save_enabled.connect(self._on_save_enabled_update) - self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.plotting_ads = AdvancedDockArea( + self, + mode="plot", + default_add_direction="bottom", + profile_namespace="developer_plotting", + auto_profile_namespace=False, + enable_profile_management=False, + variant="compact", + ) + self.plotting_ads.setObjectName("Plotting Area") self.signature_help = QTextEdit(self) + self.signature_help.setObjectName("Signature Help") self.signature_help.setAcceptRichText(True) self.signature_help.setReadOnly(True) self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) @@ -112,57 +117,85 @@ def __init__(self, parent=None, **kwargs): lambda text: self.signature_help.setHtml(markdown_to_html(text)) ) self._current_script_id: str | None = None + self.script_editor_tab = None - # Create the dock widgets - self.explorer_dock = QtAds.CDockWidget("Explorer", self) - self.explorer_dock.setWidget(self.explorer) - - self.console_dock = QtAds.CDockWidget("Console", self) - self.console_dock.setWidget(self.console) + self._initialize_layout() - self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) - self.monaco_dock.setWidget(self.monaco) - - self.terminal_dock = QtAds.CDockWidget("Terminal", self) - self.terminal_dock.setWidget(self.terminal) + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) - # Monaco will be central widget - self.dock_manager.setCentralWidget(self.monaco_dock) + self.toolbar.show_bundles(["save", "execution", "settings"]) - # Add the dock widgets to the dock manager - area_bottom = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + def _initialize_layout(self) -> None: + """Create the default dock arrangement for the developer workspace.""" + + # Monaco editor as the central dock + self.monaco_dock = self.new( + self.monaco, + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, + show_settings_action=False, + title_buttons={"float": False, "close": False, "menu": False}, + # promote_central=True, ) - self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) - area_left = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + # Explorer on the left without a title bar + self.explorer_dock = self.new( + self.explorer, + where="left", + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, ) - area_left.titleBar().setVisible(False) - - for dock in self.dock_manager.dockWidgets(): - # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea - # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, False) - self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) - self.plotting_ads_dock.setWidget(self.plotting_ads) - - self.signature_dock = QtAds.CDockWidget("Signature Help", self) - self.signature_dock.setWidget(self.signature_help) - - area_right = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + # Console and terminal tabbed along the bottom + self.console_dock = self.new( + self.console, + relative_to=self.monaco_dock, + where="bottom", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True, "close": False}, + ) + self.terminal_dock = self.new( + self.terminal, + closable=False, + floatable=False, + movable=False, + tab_with=self.console_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, ) - self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) - # Connect editor signals - self.explorer.file_open_requested.connect(self._open_new_file) - self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) + # Plotting area on the right with signature help tabbed alongside + self.plotting_ads_dock = self.new( + self.plotting_ads, + where="right", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True}, + ) + self.signature_dock = self.new( + self.signature_help, + closable=False, + floatable=False, + movable=False, + tab_with=self.plotting_ads_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, + ) - self.toolbar.show_bundles(["save", "execution", "settings"]) + self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3]) def init_developer_toolbar(self): """Initialize the developer toolbar with necessary actions and widgets.""" @@ -264,12 +297,14 @@ def _on_save_enabled_update(self, enabled: bool): @SafeSlot() def on_execute(self): + """Upload and run the currently focused script in the Monaco editor.""" self.script_editor_tab = self.monaco.last_focused_editor if not self.script_editor_tab: return - self.current_script_id = upload_script( - self.client.connector, self.script_editor_tab.widget().get_text() - ) + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return + self.current_script_id = upload_script(self.client.connector, widget.get_text()) self.console.write(f'bec._run_script("{self.current_script_id}")') print(f"Uploaded script with ID: {self.current_script_id}") @@ -303,29 +338,30 @@ def _update_subscription(self, new_script_id: str | None, old_script_id: str | N @SafeSlot(dict, dict) def on_script_execution_info(self, content: dict, metadata: dict): + """ + Handle script execution info messages to update the editor highlights. + Args: + content (dict): The content of the message containing execution info. + metadata (dict): Additional metadata for the message. + """ print(f"Script execution info: {content}") current_lines = content.get("current_lines") + if self.script_editor_tab is None: + return + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return if not current_lines: - self.script_editor_tab.widget().clear_highlighted_lines() + widget.clear_highlighted_lines() return line_number = current_lines[0] - self.script_editor_tab.widget().clear_highlighted_lines() - self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + widget.clear_highlighted_lines() + widget.set_highlighted_lines(line_number, line_number) def cleanup(self): - for dock in self.dock_manager.dockWidgets(): - self._delete_dock(dock) + self.delete_all() return super().cleanup() - def _delete_dock(self, dock: CDockWidget) -> None: - w = dock.widget() - if w and isValid(w): - w.close() - w.deleteLater() - if isValid(dock): - dock.closeDockWidget() - dock.deleteDockWidget() - if __name__ == "__main__": import sys From 6cdd813734fccd969dedafb48d1ae4c4f15b6877 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 14:52:42 +0100 Subject: [PATCH 035/155] refactor(main_app): adapted for DockAreaWidget changes --- bec_widgets/applications/main_app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 64a6b6053..2eccb7006 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -47,7 +47,10 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") - self.ads = AdvancedDockArea(self) + self.ads = AdvancedDockArea( + self, profile_namespace="main_workspace", auto_profile_namespace=False + ) + self.ads.setObjectName("MainWorkspace") self.device_manager = DeviceManagerWidget(self) self.developer_view = DeveloperView(self) From 377bad4854963357a8b9f3fe7b8746e2c2c0e1e5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 11:29:36 +0100 Subject: [PATCH 036/155] fix(widget_state_manager): filtering of not wanted properties --- bec_widgets/utils/widget_state_manager.py | 87 +++++++++++++++---- .../advanced_dock_area/advanced_dock_area.py | 4 +- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index e5f94bdc5..ae5632b86 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -3,6 +3,7 @@ import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -20,17 +21,28 @@ logger = bec_logger.logger +PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"] + class WidgetStateManager: """ - A class to manage the state of a widget by saving and loading the state to and from a INI file. + Manage saving and loading widget state to/from an INI file. Args: - widget(QWidget): The widget to manage the state for. + widget (QWidget): Root widget whose subtree will be serialized. + serialize_from_root (bool): When True, build group names relative to + this root and ignore parents above it. This keeps profiles portable + between different host window hierarchies. + root_id (str | None): Optional stable label to use for the root in + the settings key path. When omitted and `serialize_from_root` is + True, the class name of `widget` is used, falling back to its + objectName and finally to "root". """ - def __init__(self, widget): + def __init__(self, widget, *, serialize_from_root: bool = False, root_id: str | None = None): self.widget = widget + self._serialize_from_root = bool(serialize_from_root) + self._root_id = root_id def save_state(self, filename: str | None = None, settings: QSettings | None = None): """ @@ -97,15 +109,28 @@ def _save_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + + # Skip persisting QWidget visibility because container widgets (e.g. tab + # stacks, dock managers) manage that state themselves. Restoring a saved + # False can permanently hide a widget, while forcing True makes hidden + # tabs show on top. Leave the property to the parent widget instead. + if name == "visible": + continue + if ( name == "objectName" + or name in PROPERTY_TO_SKIP or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter ): continue + value = widget.property(name) + if isinstance(value, QIcon): + continue settings.setValue(name, value) + settings.endGroup() # Recursively process children (only if they aren't skipped) @@ -151,6 +176,8 @@ def _load_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + if name == "visible": + continue if settings.contains(name): value = settings.value(name) widget.setProperty(name, value) @@ -174,23 +201,51 @@ def _load_widget_state_qsettings( ): self._load_widget_state_qsettings(child, settings, False) - def _get_full_widget_name(self, widget: QWidget): + def _get_full_widget_name(self, widget: QWidget) -> str: """ - Get the full name of the widget including its parent names. + Build a group key for *widget*. - Args: - widget(QWidget): The widget to get the full name for. + When `serialize_from_root` is False (default), this preserves the original + behavior and walks all parents up to the top-level widget. - Returns: - str: The full name of the widget. + When `serialize_from_root` is True, the key is built relative to + `self.widget` and parents above the managed root are ignored. The first + path segment is either `root_id` (when provided) or a stable label derived + from the root widget (class name, then objectName, then "root"). + + Args: + widget (QWidget): The widget to build the key for. """ - name = widget.objectName() - parent = widget.parent() - while parent: - obj_name = parent.objectName() or parent.metaObject().className() - name = obj_name + "." + name - parent = parent.parent() - return name + # Backwards-compatible behavior: include the entire parent chain. + if not getattr(self, "_serialize_from_root", False): + name = widget.objectName() + parent = widget.parent() + while parent: + obj_name = parent.objectName() or parent.metaObject().className() + name = obj_name + "." + name + parent = parent.parent() + return name + + parts: list[str] = [] + current: QWidget | None = widget + + while current is not None: + if current is self.widget: + # Reached the serialization root. + root_label = self._root_id + if not root_label: + meta = current.metaObject() if hasattr(current, "metaObject") else None + class_name = meta.className() if meta is not None else "" + root_label = class_name or current.objectName() or "root" + parts.append(str(root_label)) + break + + obj_name = current.objectName() or current.metaObject().className() + parts.append(obj_name) + current = current.parent() + + parts.reverse() + return ".".join(parts) class ExampleApp(QWidget): # pragma: no cover: diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index a7a062ba1..e9a343f51 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -159,7 +159,9 @@ def __init__( self._exit_snapshot_written = False # State manager - self.state_manager = WidgetStateManager(self) + self.state_manager = WidgetStateManager( + self, serialize_from_root=True, root_id="AdvancedDockArea" + ) # Developer mode state self._editable = None From dc6946c9240646eccae0de7276ab8418d1bde89b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 15:46:55 +0100 Subject: [PATCH 037/155] fix(qt_ads): pythons stubs match structure of PySide6QtAds --- bec_widgets/__init__.py | 3 +- .../device_manager_view.py | 11 ++-- .../advanced_dock_area/advanced_dock_area.py | 4 +- .../advanced_dock_area/basic_dock_area.py | 43 +++++++------- .../advanced_dock_area/profile_utils.py | 3 +- .../containers/{ads => qt_ads}/__init__.py | 0 .../containers/{ads => qt_ads}/__init__.pyi | 56 +----------------- .../widgets/containers/qt_ads/ads/__init__.py | 0 .../containers/qt_ads/ads/__init__.pyi | 58 +++++++++++++++++++ .../widgets/editors/monaco/monaco_dock.py | 2 +- 10 files changed, 93 insertions(+), 87 deletions(-) rename bec_widgets/widgets/containers/{ads => qt_ads}/__init__.py (100%) rename bec_widgets/widgets/containers/{ads => qt_ads}/__init__.pyi (97%) create mode 100644 bec_widgets/widgets/containers/qt_ads/ads/__init__.py create mode 100644 bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 3d7d19fbd..f88f7db64 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,8 +1,7 @@ import os import sys -import PySide6QtAds as QtAds - +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 9acdb5a38..3029adae3 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -4,7 +4,6 @@ from functools import partial from typing import List, Literal -import PySide6QtAds as QtAds import yaml from bec_lib import config_helper from bec_lib.bec_yaml_loader import yaml_load @@ -12,7 +11,6 @@ from bec_lib.logger import bec_logger from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme -from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtCore import Qt, QThreadPool, QTimer from qtpy.QtWidgets import ( QDialog, @@ -28,6 +26,7 @@ QWidget, ) +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.help_inspector.help_inspector import HelpInspector @@ -158,7 +157,7 @@ def __init__(self, parent=None, *args, **kwargs): self._root_layout = QVBoxLayout(self) self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setSpacing(0) - self.dock_manager = CDockManager(self) + self.dock_manager = QtAds.CDockManager(self) self.dock_manager.setStyleSheet("") self._root_layout.addWidget(self.dock_manager) @@ -237,9 +236,9 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) for dock in self.dock_manager.dockWidgets(): - dock.setFeature(CDockWidget.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False) # Apply stretch after the layout is done self.set_default_view([2, 8, 2], [7, 3]) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index e9a343f51..3bdadcf71 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -3,9 +3,7 @@ import os from typing import Callable, Literal, Mapping, Sequence -import PySide6QtAds as QtAds from bec_lib import bec_logger -from PySide6QtAds import CDockWidget from qtpy.QtCore import QTimer, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( @@ -18,6 +16,7 @@ QWidget, ) +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher @@ -66,6 +65,7 @@ workspace_bundle, ) from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC +from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D from bec_widgets.widgets.control.scan_control import ScanControl from bec_widgets.widgets.editors.web_console.web_console import WebConsole diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index c839b6139..864d56942 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -5,18 +5,17 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast from bec_qthemes import material_icon -from PySide6QtAds import ads from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer from qtpy.QtGui import QIcon from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget from shiboken6 import isValid -import bec_widgets.widgets.containers.ads as QtAds +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.widgets.containers.ads import ( +from bec_widgets.widgets.containers.qt_ads import ( CDockAreaWidget, CDockManager, CDockSplitter, @@ -70,7 +69,7 @@ class DockCreationSpec: tab_with: CDockWidget | None = None relative_to: CDockWidget | None = None title_visible: bool | None = None - title_buttons: Mapping[ads.TitleBarButton, bool] | None = None + title_buttons: Mapping[QtAds.ads.TitleBarButton, bool] | None = None show_settings_action: bool | None = False dock_preferences: Mapping[str, Any] | None = None promote_central: bool = False @@ -979,38 +978,38 @@ def set_layout_ratios( self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) @staticmethod - def _title_bar_button_enum(name: str) -> ads.TitleBarButton | None: + def _title_bar_button_enum(name: str) -> QtAds.ads.TitleBarButton | None: """Translate a user-friendly button name into an ADS TitleBarButton enum.""" normalized = (name or "").lower().replace("-", "_").replace(" ", "_") - mapping: dict[str, ads.TitleBarButton] = { - "menu": ads.TitleBarButton.TitleBarButtonTabsMenu, - "tabs_menu": ads.TitleBarButton.TitleBarButtonTabsMenu, - "tabs": ads.TitleBarButton.TitleBarButtonTabsMenu, - "undock": ads.TitleBarButton.TitleBarButtonUndock, - "float": ads.TitleBarButton.TitleBarButtonUndock, - "detach": ads.TitleBarButton.TitleBarButtonUndock, - "close": ads.TitleBarButton.TitleBarButtonClose, - "auto_hide": ads.TitleBarButton.TitleBarButtonAutoHide, - "autohide": ads.TitleBarButton.TitleBarButtonAutoHide, - "minimize": ads.TitleBarButton.TitleBarButtonMinimize, + mapping: dict[str, QtAds.ads.TitleBarButton] = { + "menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "float": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "detach": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "close": QtAds.ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": QtAds.ads.TitleBarButton.TitleBarButtonMinimize, } return mapping.get(normalized) def _normalize_title_buttons( self, spec: ( - Mapping[str | ads.TitleBarButton, bool] - | Sequence[str | ads.TitleBarButton] + Mapping[str | QtAds.ads.TitleBarButton, bool] + | Sequence[str | QtAds.ads.TitleBarButton] | str - | ads.TitleBarButton + | QtAds.ads.TitleBarButton | None ), - ) -> dict[ads.TitleBarButton, bool]: + ) -> dict[QtAds.ads.TitleBarButton, bool]: """Normalize button visibility specifications into an enum mapping.""" if spec is None: return {} - result: dict[ads.TitleBarButton, bool] = {} + result: dict[QtAds.ads.TitleBarButton, bool] = {} if isinstance(spec, Mapping): iterator = spec.items() else: @@ -1019,7 +1018,7 @@ def _normalize_title_buttons( iterator = ((name, False) for name in spec) for name, visible in iterator: - if isinstance(name, ads.TitleBarButton): + if isinstance(name, QtAds.ads.TitleBarButton): enum = name else: enum = self._title_bar_button_enum(str(name)) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 59c4847bd..9e239b033 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -20,10 +20,11 @@ from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field -from PySide6QtAds import CDockWidget from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +from bec_widgets.widgets.containers.qt_ads import CDockWidget + logger = bec_logger.logger MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/bec_widgets/widgets/containers/ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/__init__.py similarity index 100% rename from bec_widgets/widgets/containers/ads/__init__.py rename to bec_widgets/widgets/containers/qt_ads/__init__.py diff --git a/bec_widgets/widgets/containers/ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/__init__.pyi similarity index 97% rename from bec_widgets/widgets/containers/ads/__init__.pyi rename to bec_widgets/widgets/containers/qt_ads/__init__.pyi index 7bb78f08b..dfc1232f4 100644 --- a/bec_widgets/widgets/containers/ads/__init__.pyi +++ b/bec_widgets/widgets/containers/qt_ads/__init__.pyi @@ -7,60 +7,10 @@ import typing from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal -# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called - -class SideBarLocation(enum.Enum): - SideBarTop = ... - SideBarLeft = ... - SideBarRight = ... - SideBarBottom = ... - SideBarNone = ... - -class eBitwiseOperator(enum.Enum): - BitwiseAnd = ... - BitwiseOr = ... - -class eIcon(enum.Enum): - TabCloseIcon = ... - AutoHideIcon = ... - DockAreaMenuIcon = ... - DockAreaUndockIcon = ... - DockAreaCloseIcon = ... - DockAreaMinimizeIcon = ... - IconCount = ... +from bec_widgets.widgets.containers.qt_ads import ads +from bec_widgets.widgets.containers.qt_ads.ads import * -class eDragState(enum.Enum): - DraggingInactive = ... - DraggingMousePressed = ... - DraggingTab = ... - DraggingFloatingWidget = ... - -class TitleBarButton(enum.Enum): - TitleBarButtonTabsMenu = ... - TitleBarButtonUndock = ... - TitleBarButtonClose = ... - TitleBarButtonAutoHide = ... - TitleBarButtonMinimize = ... - -class eTabIndex(enum.Enum): - TabDefaultInsertIndex = ... - TabInvalidIndex = ... - -class DockWidgetArea(enum.Enum): - NoDockWidgetArea = ... - LeftDockWidgetArea = ... - RightDockWidgetArea = ... - TopDockWidgetArea = ... - BottomDockWidgetArea = ... - CenterDockWidgetArea = ... - LeftAutoHideArea = ... - RightAutoHideArea = ... - TopAutoHideArea = ... - BottomAutoHideArea = ... - InvalidDockWidgetArea = ... - OuterDockAreas = ... - AutoHideDockAreas = ... - AllDockAreas = ... +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called class CAutoHideDockContainer(QtWidgets.QFrame): def __init__( diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/ads/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi new file mode 100644 index 000000000..30b3a90af --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi @@ -0,0 +1,58 @@ +from __future__ import annotations + +import enum + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class SideBarLocation(enum.Enum): + SideBarTop = ... + SideBarLeft = ... + SideBarRight = ... + SideBarBottom = ... + SideBarNone = ... + +class eBitwiseOperator(enum.Enum): + BitwiseAnd = ... + BitwiseOr = ... + +class eIcon(enum.Enum): + TabCloseIcon = ... + AutoHideIcon = ... + DockAreaMenuIcon = ... + DockAreaUndockIcon = ... + DockAreaCloseIcon = ... + DockAreaMinimizeIcon = ... + IconCount = ... + +class eDragState(enum.Enum): + DraggingInactive = ... + DraggingMousePressed = ... + DraggingTab = ... + DraggingFloatingWidget = ... + +class TitleBarButton(enum.Enum): + TitleBarButtonTabsMenu = ... + TitleBarButtonUndock = ... + TitleBarButtonClose = ... + TitleBarButtonAutoHide = ... + TitleBarButtonMinimize = ... + +class eTabIndex(enum.Enum): + TabDefaultInsertIndex = ... + TabInvalidIndex = ... + +class DockWidgetArea(enum.Enum): + NoDockWidgetArea = ... + LeftDockWidgetArea = ... + RightDockWidgetArea = ... + TopDockWidgetArea = ... + BottomDockWidgetArea = ... + CenterDockWidgetArea = ... + LeftAutoHideArea = ... + RightAutoHideArea = ... + TopAutoHideArea = ... + BottomAutoHideArea = ... + InvalidDockWidgetArea = ... + OuterDockAreas = ... + AutoHideDockAreas = ... + AllDockAreas = ... diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 8b8cd1912..3186d82c4 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -9,8 +9,8 @@ from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget -from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger From 1d0634e14215791e6ad9d61dc56403a5582d5215 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 16:18:36 +0100 Subject: [PATCH 038/155] fix(bec_widget): improved qt enums; grab safeguard --- bec_widgets/utils/bec_widget.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 1660f58c0..dd94d0c81 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,13 +3,13 @@ from datetime import datetime from typing import TYPE_CHECKING -import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QWidget +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.error_popups import SafeConnect, SafeSlot @@ -242,16 +242,22 @@ def screenshot_bytes( """ if not isinstance(self, QWidget): return QByteArray() + + if not hasattr(self, "grab"): + raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}") + pixmap: QPixmap = self.grab() if pixmap.isNull(): return QByteArray() if max_width is not None or max_height is not None: w = max_width if max_width is not None else pixmap.width() h = max_height if max_height is not None else pixmap.height() - pixmap = pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + pixmap = pixmap.scaled( + w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation + ) ba = QByteArray() buf = QBuffer(ba) - buf.open(QIODevice.WriteOnly) + buf.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buf, fmt, quality) buf.close() return ba From 8842eb617ace86e0a5d32f54f87ba38523c36754 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 15:33:41 +0100 Subject: [PATCH 039/155] fix(widgets): removed isVisible from all SafeProperties --- bec_widgets/utils/compact_popup.py | 4 ++- .../positioner_box/positioner_box.py | 4 ++- .../positioner_box_2d/positioner_box_2d.py | 8 +++-- .../control/scan_control/scan_control.py | 31 ++++++++++++------- .../widgets/dap/lmfit_dialog/lmfit_dialog.py | 12 +++++-- .../editors/scan_metadata/scan_metadata.py | 4 ++- bec_widgets/widgets/plots/plot_base.py | 25 +++++++++++---- .../scan_progressbar/scan_progressbar.py | 12 +++++-- .../widgets/services/bec_queue/bec_queue.py | 4 ++- 9 files changed, 74 insertions(+), 30 deletions(-) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index 8d4daef24..af8b48a2d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -144,6 +144,7 @@ def __init__(self, parent=None, layout=QVBoxLayout): self.container.setVisible(True) layout(self.container) self.layout = self.container.layout() + self._compact_view = False self.compact_show_popup.clicked.connect(self.show_popup) @@ -210,7 +211,7 @@ def addWidget(self, widget): @Property(bool) def compact_view(self): - return self.compact_label.isVisible() + return self._compact_view @compact_view.setter def compact_view(self, set_compact: bool): @@ -220,6 +221,7 @@ def compact_view(self, set_compact: bool): the full view is displayed. This is handled by toggling visibility of the container widget or the compact view widget. """ + self._compact_view = set_compact if set_compact: self.compact_view_widget.setVisible(True) self.container.setVisible(False) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index a573623e6..28d779999 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -49,6 +49,7 @@ def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs self._device = "" self._limits = None + self._hide_device_selection = False if self.current_path == "": self.current_path = os.path.dirname(__file__) @@ -114,11 +115,12 @@ def device(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button.setVisible(not value) @SafeSlot(bool) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 298a2f076..689c05141 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -73,6 +73,8 @@ def __init__( self._limits_hor = None self._limits_ver = None self._dialog = None + self._hide_device_selection = False + self._hide_device_boxes = False self._enable_controls_hor = True self._enable_controls_ver = True if self.current_path == "": @@ -225,22 +227,24 @@ def device_ver(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button_hor.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button_hor.setVisible(not value) self.ui.tool_button_ver.setVisible(not value) @SafeProperty(bool) def hide_device_boxes(self): """Hide the device selection""" - return not self.ui.device_box_hor.isVisible() + return self._hide_device_boxes @hide_device_boxes.setter def hide_device_boxes(self, value: bool): """Set the device selection visibility""" + self._hide_device_boxes = value self.ui.device_box_hor.setVisible(not value) self.ui.device_box_ver.setVisible(not value) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 6bbef6e08..f1ef4b2b7 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -91,6 +91,11 @@ def __init__( self._scan_metadata: dict | None = None self._metadata_form = ScanMetadata(parent=self) + self._hide_arg_box = False + self._hide_kwarg_boxes = False + self._hide_scan_control_buttons = False + self._hide_metadata = False + self._hide_scan_selection_combobox = False # Create and set main layout self._init_UI() @@ -262,9 +267,7 @@ def set_current_scan(self, scan_name: str): @SafeProperty(bool) def hide_arg_box(self): """Property to hide the argument box.""" - if self.arg_box is None: - return True - return not self.arg_box.isVisible() + return self._hide_arg_box @hide_arg_box.setter def hide_arg_box(self, hide: bool): @@ -273,18 +276,14 @@ def hide_arg_box(self, hide: bool): Args: hide(bool): Hide or show the argument box. """ + self._hide_arg_box = hide if self.arg_box is not None: self.arg_box.setVisible(not hide) @SafeProperty(bool) def hide_kwarg_boxes(self): """Property to hide the keyword argument boxes.""" - if len(self.kwarg_boxes) == 0: - return True - - for box in self.kwarg_boxes: - if box is not None: - return not box.isVisible() + return self._hide_kwarg_boxes @hide_kwarg_boxes.setter def hide_kwarg_boxes(self, hide: bool): @@ -293,6 +292,7 @@ def hide_kwarg_boxes(self, hide: bool): Args: hide(bool): Hide or show the keyword argument boxes. """ + self._hide_kwarg_boxes = hide if len(self.kwarg_boxes) > 0: for box in self.kwarg_boxes: box.setVisible(not hide) @@ -300,7 +300,7 @@ def hide_kwarg_boxes(self, hide: bool): @SafeProperty(bool) def hide_scan_control_buttons(self): """Property to hide the scan control buttons.""" - return not self.button_run_scan.isVisible() + return self._hide_scan_control_buttons @hide_scan_control_buttons.setter def hide_scan_control_buttons(self, hide: bool): @@ -309,12 +309,13 @@ def hide_scan_control_buttons(self, hide: bool): Args: hide(bool): Hide or show the scan control buttons. """ + self._hide_scan_control_buttons = hide self.show_scan_control_buttons(not hide) @SafeProperty(bool) def hide_metadata(self): """Property to hide the metadata form.""" - return not self._metadata_form.isVisible() + return self._hide_metadata @hide_metadata.setter def hide_metadata(self, hide: bool): @@ -323,6 +324,7 @@ def hide_metadata(self, hide: bool): Args: hide(bool): Hide or show the metadata form. """ + self._hide_metadata = hide self._metadata_form.setVisible(not hide) @SafeProperty(bool) @@ -342,12 +344,13 @@ def hide_optional_metadata(self, hide: bool): @SafeSlot(bool) def show_scan_control_buttons(self, show: bool): """Shows or hides the scan control buttons.""" + self._hide_scan_control_buttons = not show self.scan_control_group.setVisible(show) @SafeProperty(bool) def hide_scan_selection_combobox(self): """Property to hide the scan selection combobox.""" - return not self.comboBox_scan_selection.isVisible() + return self._hide_scan_selection_combobox @hide_scan_selection_combobox.setter def hide_scan_selection_combobox(self, hide: bool): @@ -356,11 +359,13 @@ def hide_scan_selection_combobox(self, hide: bool): Args: hide(bool): Hide or show the scan selection combobox. """ + self._hide_scan_selection_combobox = hide self.show_scan_selection_combobox(not hide) @SafeSlot(bool) def show_scan_selection_combobox(self, show: bool): """Shows or hides the scan selection combobox.""" + self._hide_scan_selection_combobox = not show self.scan_selection_group.setVisible(show) @SafeSlot(str) @@ -415,6 +420,7 @@ def add_kwargs_boxes(self, groups: list): box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.layout.insertWidget(position + len(self.kwarg_boxes), box) self.kwarg_boxes.append(box) + box.setVisible(not self._hide_kwarg_boxes) def add_arg_group(self, group: dict): """ @@ -427,6 +433,7 @@ def add_arg_group(self, group: dict): self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box) + self.arg_box.setVisible(not self._hide_arg_box) @SafeSlot(str) def emit_device_selected(self, dev_names): diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py index 05a5623c4..68870b605 100644 --- a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py @@ -65,6 +65,9 @@ def __init__( self._move_buttons = [] self._accent_colors = get_accent_colors() self.action_buttons = {} + self._hide_curve_selection = False + self._hide_summary = False + self._hide_parameters = False @property def enable_actions(self) -> bool: @@ -108,7 +111,7 @@ def always_show_latest(self, show: bool): @SafeProperty(bool) def hide_curve_selection(self): """SafeProperty for showing the curve selection.""" - return not self.ui.group_curve_selection.isVisible() + return self._hide_curve_selection @hide_curve_selection.setter def hide_curve_selection(self, show: bool): @@ -117,12 +120,13 @@ def hide_curve_selection(self, show: bool): Args: show (bool): Whether to show the curve selection. """ + self._hide_curve_selection = show self.ui.group_curve_selection.setVisible(not show) @SafeProperty(bool) def hide_summary(self) -> bool: """SafeProperty for showing the summary.""" - return not self.ui.group_summary.isVisible() + return self._hide_summary @hide_summary.setter def hide_summary(self, show: bool): @@ -131,12 +135,13 @@ def hide_summary(self, show: bool): Args: show (bool): Whether to show the summary. """ + self._hide_summary = show self.ui.group_summary.setVisible(not show) @SafeProperty(bool) def hide_parameters(self) -> bool: """SafeProperty for showing the parameters.""" - return not self.ui.group_parameters.isVisible() + return self._hide_parameters @hide_parameters.setter def hide_parameters(self, show: bool): @@ -145,6 +150,7 @@ def hide_parameters(self, show: bool): Args: show (bool): Whether to show the parameters. """ + self._hide_parameters = show self.ui.group_parameters.setVisible(not show) @property diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index bd75081d6..5df774f02 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -49,6 +49,7 @@ def __init__( self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._additional_metadata.data_changed.connect(self.validate_form) + self._hide_optional_metadata = False super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) @@ -63,7 +64,7 @@ def update_with_new_scan(self, scan_name: str): @SafeProperty(bool) def hide_optional_metadata(self): # type: ignore """Property to hide the optional metadata table.""" - return not self._additional_md_box.isVisible() + return self._hide_optional_metadata @hide_optional_metadata.setter def hide_optional_metadata(self, hide: bool): @@ -72,6 +73,7 @@ def hide_optional_metadata(self, hide: bool): Args: hide(bool): Hide or show the optional metadata table. """ + self._hide_optional_metadata = hide self._additional_md_box.setVisible(not hide) def get_form_data(self): diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 823ac6de5..908a16dd8 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -173,6 +173,12 @@ def __init__( self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item) + # Visibility States + self._toolbar_visible = True + self._enable_fps_monitor = False + self._outer_axes_visible = self.plot_item.getAxis("top").isVisible() + self._inner_axes_visible = self.plot_item.getAxis("bottom").isVisible() + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") self._init_toolbar() @@ -338,7 +344,7 @@ def enable_toolbar(self) -> bool: """ Show Toolbar. """ - return self.toolbar.isVisible() + return self._toolbar_visible @enable_toolbar.setter def enable_toolbar(self, value: bool): @@ -348,6 +354,7 @@ def enable_toolbar(self, value: bool): Args: value(bool): The value to set. """ + self._toolbar_visible = value self.toolbar.setVisible(value) @SafeProperty(bool, doc="Enable the FPS monitor.") @@ -355,7 +362,7 @@ def enable_fps_monitor(self) -> bool: """ Enable the FPS monitor. """ - return self.fps_label.isVisible() + return self._enable_fps_monitor @enable_fps_monitor.setter def enable_fps_monitor(self, value: bool): @@ -365,9 +372,11 @@ def enable_fps_monitor(self, value: bool): Args: value(bool): The value to set. """ - if value and self.fps_monitor is None: + if value == self._enable_fps_monitor: + return + if value: self.hook_fps_monitor() - elif not value and self.fps_monitor is not None: + else: self.unhook_fps_monitor() ################################################################################ @@ -840,7 +849,7 @@ def outer_axes(self) -> bool: """ Show the outer axes of the plot widget. """ - return self.plot_item.getAxis("top").isVisible() + return self._outer_axes_visible @outer_axes.setter def outer_axes(self, value: bool): @@ -853,6 +862,7 @@ def outer_axes(self, value: bool): self.plot_item.showAxis("top", value) self.plot_item.showAxis("right", value) + self._outer_axes_visible = value self.property_changed.emit("outer_axes", value) @SafeProperty(bool, doc="Show inner axes of the plot widget.") @@ -860,7 +870,7 @@ def inner_axes(self) -> bool: """ Show inner axes of the plot widget. """ - return self.plot_item.getAxis("bottom").isVisible() + return self._inner_axes_visible @inner_axes.setter def inner_axes(self, value: bool): @@ -873,6 +883,7 @@ def inner_axes(self, value: bool): self.plot_item.showAxis("bottom", value) self.plot_item.showAxis("left", value) + self._inner_axes_visible = value self._apply_x_label() self._apply_y_label() self.property_changed.emit("inner_axes", value) @@ -1047,6 +1058,7 @@ def hook_fps_monitor(self): self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label) self.update_fps_label(0) + self._enable_fps_monitor = True def unhook_fps_monitor(self, delete_label=True): """Unhook the FPS monitor from the plot.""" @@ -1058,6 +1070,7 @@ def unhook_fps_monitor(self, delete_label=True): if self.fps_label is not None: # Hide Label self.fps_label.hide() + self._enable_fps_monitor = False ################################################################################ # Crosshair diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py index 2ebce2c80..2fada11db 100644 --- a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py +++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py @@ -146,6 +146,9 @@ def __init__( self.layout.addWidget(self.ui) self.setLayout(self.layout) self.progressbar = self.ui.progressbar + self._show_elapsed_time = self.ui.elapsed_time_label.isVisible() + self._show_remaining_time = self.ui.remaining_time_label.isVisible() + self._show_source_label = self.ui.source_label.isVisible() self.connect_to_queue() self._progress_source = None @@ -222,30 +225,33 @@ def on_progress_update(self, msg_content: dict, metadata: dict): @SafeProperty(bool) def show_elapsed_time(self): - return self.ui.elapsed_time_label.isVisible() + return self._show_elapsed_time @show_elapsed_time.setter def show_elapsed_time(self, value): + self._show_elapsed_time = value self.ui.elapsed_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_remaining_time(self): - return self.ui.remaining_time_label.isVisible() + return self._show_remaining_time @show_remaining_time.setter def show_remaining_time(self, value): + self._show_remaining_time = value self.ui.remaining_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_source_label(self): - return self.ui.source_label.isVisible() + return self._show_source_label @show_source_label.setter def show_source_label(self, value): + self._show_source_label = value self.ui.source_label.setVisible(value) def update_labels(self): diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index b2dff0d75..6ad4c93ff 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -52,6 +52,7 @@ def __init__( ) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + self._toolbar_hidden = False # Set up the toolbar self.set_toolbar() @@ -105,7 +106,7 @@ def set_toolbar(self): @Property(bool) def hide_toolbar(self): """Property to hide the BEC Queue toolbar.""" - return not self.toolbar.isVisible() + return self._toolbar_hidden @hide_toolbar.setter def hide_toolbar(self, hide: bool): @@ -124,6 +125,7 @@ def _hide_toolbar(self, hide: bool): Args: hide(bool): Whether to hide the toolbar. """ + self._toolbar_hidden = hide self.toolbar.setVisible(not hide) def refresh_queue(self): From 7daa25d7c1b936c52c700130d14c48f327e9f734 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 11:32:27 +0100 Subject: [PATCH 040/155] feat(advanced_dock_area): instance lock for multiple ads in same session --- .../views/developer_view/developer_widget.py | 4 +- .../advanced_dock_area/advanced_dock_area.py | 76 +++++++++++++------ .../advanced_dock_area/profile_utils.py | 49 ++++++++++-- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 21f9d1854..ce7030c3a 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -93,7 +93,7 @@ def __init__(self, parent=None, **kwargs): self.terminal = WebConsole(self, startup_cmd="") self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) - self.monaco.setObjectName("Monaco Editor") + self.monaco.setObjectName("MonacoEditor") self.monaco.save_enabled.connect(self._on_save_enabled_update) self.plotting_ads = AdvancedDockArea( self, @@ -104,7 +104,7 @@ def __init__(self, parent=None, **kwargs): enable_profile_management=False, variant="compact", ) - self.plotting_ads.setObjectName("Plotting Area") + self.plotting_ads.setObjectName("PlottingArea") self.signature_help = QTextEdit(self) self.signature_help.setObjectName("Signature Help") self.signature_help.setAcceptRichText(True) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 3bdadcf71..4cac5392c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -118,6 +118,7 @@ def __init__( default_add_direction: Literal["left", "right", "top", "bottom"] = "right", profile_namespace: str | None = None, auto_profile_namespace: bool = True, + instance_id: str | None = None, auto_save_upon_exit: bool = True, enable_profile_management: bool = True, restore_initial_profile: bool = True, @@ -126,6 +127,7 @@ def __init__( self._profile_namespace_hint = profile_namespace self._profile_namespace_auto = auto_profile_namespace self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET + self._instance_id = sanitize_namespace(instance_id) if instance_id else None self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._restore_initial_profile = restore_initial_profile @@ -181,24 +183,23 @@ def _fetch_initial_profile(self): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_namespace - last = get_last_profile(namespace) - if last: - user_exists = any( - os.path.exists(path) for path in user_profile_candidates(last, namespace) + init_profile = None + instance_id = self._last_profile_instance_id() + if instance_id: + inst_profile = get_last_profile( + namespace=namespace, instance=instance_id, allow_namespace_fallback=False ) - default_exists = any( - os.path.exists(path) for path in default_profile_candidates(last, namespace) - ) - init_profile = last if (user_exists or default_exists) else None - else: - init_profile = combo.currentText() + if inst_profile and self._profile_exists(inst_profile, namespace): + init_profile = inst_profile if not init_profile: - general_exists = any( - os.path.exists(path) for path in user_profile_candidates("general", namespace) - ) or any( - os.path.exists(path) for path in default_profile_candidates("general", namespace) - ) - if general_exists: + last = get_last_profile(namespace=namespace) + if last and self._profile_exists(last, namespace): + init_profile = last + else: + text = combo.currentText() + init_profile = text if text else None + if not init_profile: + if self._profile_exists("general", namespace): init_profile = "general" if init_profile: # Defer initial load to the event loop so child widgets exist before state restore. @@ -500,6 +501,14 @@ def lock_workspace(self, value: bool): for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _last_profile_instance_id(self) -> str | None: + """ + Identifier used to scope the last-profile entry for this dock area. + + When unset, profiles are scoped only by namespace. + """ + return self._instance_id + def _resolve_profile_namespace(self) -> str | None: if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: return self._profile_namespace_resolved # type: ignore[return-value] @@ -536,6 +545,11 @@ def _active_profile_name_or_default(self) -> str: self._current_profile_name = name return name + def _profile_exists(self, name: str, namespace: str | None) -> bool: + return any( + os.path.exists(path) for path in user_profile_candidates(name, namespace) + ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) @@ -630,7 +644,7 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.setCurrentText(name) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -688,7 +702,7 @@ def load_profile(self, name: str | None = None): self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -889,7 +903,7 @@ def prepare_for_shutdown(self) -> None: namespace = self.profile_namespace settings = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(settings) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) self._exit_snapshot_written = True def cleanup(self): @@ -910,13 +924,31 @@ def cleanup(self): if __name__ == "__main__": # pragma: no cover import sys + from qtpy.QtWidgets import QTabWidget + app = QApplication(sys.argv) apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True) - window.setCentralWidget(ads) + central = QWidget() + layout = QVBoxLayout(central) + window.setCentralWidget(central) + + # two dock areas stacked vertically no instance ids + ads = AdvancedDockArea(mode="creator", enable_profile_management=True) + ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True) + layout.addWidget(ads, 1) + layout.addWidget(ads2, 1) + + # two dock areas inside a tab widget + tabs = QTabWidget(parent=central) + ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3") + ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4") + tabs.addTab(ads3, "Workspace 3") + tabs.addTab(ads4, "Workspace 4") + layout.addWidget(tabs, 1) + window.show() - window.resize(800, 600) + window.resize(800, 1000) sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 9e239b033..f09f5c544 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -559,9 +559,10 @@ def _app_settings() -> QSettings: return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) -def _last_profile_key(namespace: str | None) -> str: +def _last_profile_key(namespace: str | None, instance: str | None = None) -> str: """ - Build the QSettings key used to store the last profile per namespace. + Build the QSettings key used to store the last profile per namespace and + optional instance id. Args: namespace (str | None): Namespace label. @@ -571,37 +572,69 @@ def _last_profile_key(namespace: str | None) -> str: """ ns = sanitize_namespace(namespace) key = SETTINGS_KEYS["last_profile"] - return f"{key}/{ns}" if ns else key + if ns: + key = f"{key}/{ns}" + inst = sanitize_namespace(instance) if instance else "" + if inst: + key = f"{key}@{inst}" + return key -def get_last_profile(namespace: str | None = None) -> str | None: +def get_last_profile( + namespace: str | None = None, + instance: str | None = None, + *, + allow_namespace_fallback: bool = True, +) -> str | None: """ Retrieve the last-used profile name persisted in app settings. + When *instance* is provided, the lookup is scoped to that particular dock + area instance. If the instance-specific entry is missing and + ``allow_namespace_fallback`` is True, the namespace-wide entry is + consulted next. + Args: namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. + allow_namespace_fallback (bool): Whether to fall back to the namespace + entry when an instance-specific value is not found. Defaults to ``True``. Returns: str | None: Profile name or ``None`` if none has been stored. """ s = _app_settings() - name = s.value(_last_profile_key(namespace), "", type=str) + inst = instance or None + if inst: + name = s.value(_last_profile_key(namespace, inst), "", type=str) + if name: + return name + if not allow_namespace_fallback: + return None + name = s.value(_last_profile_key(namespace, None), "", type=str) return name or None -def set_last_profile(name: str | None, namespace: str | None = None) -> None: +def set_last_profile( + name: str | None, namespace: str | None = None, instance: str | None = None +) -> None: """ Persist the last-used profile name (or clear the value when ``None``). + When *instance* is provided, the value is stored under a key specific to + that dock area instance; otherwise it is stored under the namespace-wide key. + Args: name (str | None): Profile name to store. namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. """ s = _app_settings() + key = _last_profile_key(namespace, instance) if name: - s.setValue(_last_profile_key(namespace), name) + s.setValue(key, name) else: - s.remove(_last_profile_key(namespace)) + s.remove(key) def now_iso_utc() -> str: From 08dde431a6803e6735d9e75642ed57e12986548f Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sun, 23 Nov 2025 13:28:20 +0100 Subject: [PATCH 041/155] refactor: improvements to enum access --- .../advanced_dock_area/advanced_dock_area.py | 13 ++++++----- .../toolbar_components/workspace_actions.py | 18 ++++++++------- .../explorer/collapsible_tree_section.py | 23 +++---------------- .../containers/main_window/main_window.py | 19 +++++---------- .../positioner_box_2d/positioner_box_2d.py | 5 ++-- .../control/scan_control/scan_control.py | 14 +++++++---- .../widgets/services/bec_queue/bec_queue.py | 2 +- 7 files changed, 38 insertions(+), 56 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 4cac5392c..83b88a7e2 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -596,7 +596,7 @@ def _profile_exists(profile_name: str) -> bool: origin_label=lambda n: profile_origin_display(n, namespace=namespace), quick_select_checked=quickselect_default, ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return name = dialog.get_profile_name() @@ -760,10 +760,10 @@ def delete_profile(self): "Delete Profile", f"Are you sure you want to delete the profile '{name}'?\n\n" f"This action cannot be undone.", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply != QMessageBox.Yes: + if reply != QMessageBox.StandardButton.Yes: return namespace = self.profile_namespace @@ -833,8 +833,9 @@ def show_workspace_manager(self): def _manage_dialog_closed(self): self.manage_widget.close() self.manage_widget.deleteLater() - self.manage_dialog.deleteLater() - self.manage_dialog = None + if self.manage_dialog is not None: + self.manage_dialog.deleteLater() + self.manage_dialog = None self.toolbar.components.get_action("manage_workspaces").action.setChecked(False) ################################################################################ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index cb7dabea1..58bb8cbe9 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -18,7 +18,7 @@ class ProfileComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._quick_provider: Callable[[], list[str]] = list_quick_profiles def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: @@ -48,9 +48,9 @@ def refresh_profiles(self, active_profile: str | None = None): idx = self.count() - 1 # Reset any custom styling - self.setItemData(idx, None, Qt.FontRole) - self.setItemData(idx, None, Qt.ToolTipRole) - self.setItemData(idx, None, Qt.ForegroundRole) + self.setItemData(idx, None, Qt.ItemDataRole.FontRole) + self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole) + self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole) if active_profile and profile == active_profile: tooltip = "Active workspace profile" @@ -58,13 +58,15 @@ def refresh_profiles(self, active_profile: str | None = None): font = QFont(self.font()) font.setItalic(True) font.setBold(True) - self.setItemData(idx, font, Qt.FontRole) - self.setItemData(idx, self.palette().highlight().color(), Qt.ForegroundRole) + self.setItemData(idx, font, Qt.ItemDataRole.FontRole) + self.setItemData( + idx, self.palette().highlight().color(), Qt.ItemDataRole.ForegroundRole + ) tooltip = "Active profile (not in quick select)" - self.setItemData(idx, tooltip, Qt.ToolTipRole) + self.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) self.setCurrentIndex(idx) elif profile not in quick_set: - self.setItemData(idx, "Not in quick select", Qt.ToolTipRole) + self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole) # Restore selection if possible index = self.findText(current_text) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index 3c9d9863f..ca15a3ce0 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -5,7 +5,6 @@ from qtpy.QtGui import QDrag from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget -from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.error_popups import SafeProperty @@ -49,6 +48,8 @@ def __init__( # Create header button self.header_button = QPushButton() + # Apply theme variant for title styling + self.header_button.setProperty("variant", "title") self.header_button.clicked.connect(self.toggle_expanded) # Enable drag and drop for reordering @@ -72,7 +73,7 @@ def __init__( self.header_add_button.setFixedSize(28, 28) self.header_add_button.setToolTip("Add item") self.header_add_button.setVisible(show_add_button) - self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.header_add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.header_add_button.setAutoRaise(True) self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False)) @@ -105,24 +106,6 @@ def _update_appearance(self): self.header_button.setIcon(icon) self.header_button.setText(self.title) - # Get theme colors - palette = get_theme_palette() - text_color = palette.text().color().name() - - self.header_button.setStyleSheet( - f""" - QPushButton {{ - font-weight: bold; - text-align: left; - margin: 0; - padding: 0px; - border: none; - background: transparent; - icon-size: 20px 20px; - }} - """ - ) - def toggle_expanded(self): """Toggle the expanded state and update size policy""" self.expanded = not self.expanded diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index a5990e6db..34a01875f 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEvent, QSize, Qt, QTimer @@ -34,7 +35,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) # Ensure the application does not use the native menu bar on macOS to be consistent with linux development. -QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True) +QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True) class BECMainWindow(BECWidget, QMainWindow): @@ -43,16 +44,8 @@ class BECMainWindow(BECWidget, QMainWindow): SCAN_PROGRESS_WIDTH = 100 # px SCAN_PROGRESS_HEIGHT = 12 # px - def __init__( - self, - parent=None, - gui_id: str = None, - client=None, - window_title: str = "BEC", - *args, - **kwargs, - ): - super().__init__(parent=parent, gui_id=gui_id, **kwargs) + def __init__(self, parent=None, window_title: str = "BEC", **kwargs): + super().__init__(parent=parent, **kwargs) self.app = QApplication.instance() self.status_bar = self.statusBar() @@ -330,8 +323,8 @@ def _setup_menu_bar(self): # Help menu help_menu = menu_bar.addMenu("Help") - help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) - bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) + help_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) + bug_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) bec_docs = QAction("BEC Docs", self) bec_docs.setIcon(help_icon) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 689c05141..d57c22c6b 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -333,7 +333,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_hor, "units": self.ui.units_hor, } - elif device == "vertical": + if device == "vertical": return { "spinner": self.ui.spinner_widget_ver, "position_indicator": self.ui.position_indicator_ver, @@ -346,8 +346,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_ver, "units": self.ui.units_ver, } - else: - raise ValueError(f"Device {device} is not represented by this UI") + raise ValueError(f"Device {device} is not represented by this UI") def _device_ui_components(self, device: str): if device == self.device_hor: diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index f1ef4b2b7..1a633ec3a 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -125,7 +125,7 @@ def _init_UI(self): # Label to reload the last scan parameters within scan selection group box self.toggle_layout = QHBoxLayout() self.toggle_layout.addSpacerItem( - QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed) + QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) ) self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group) self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False) @@ -133,12 +133,16 @@ def _init_UI(self): self.toggle_layout.addWidget(self.last_scan_label) self.toggle_layout.addWidget(self.toggle) self.scan_selection_group.layout().addLayout(self.toggle_layout) - self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_selection_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.layout.addWidget(self.scan_selection_group) # Scan control (Run/Stop) buttons self.scan_control_group = QWidget(self) - self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_control_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.button_layout = QHBoxLayout(self.scan_control_group) self.button_run_scan = QPushButton("Start", self.scan_control_group) self.button_run_scan.setProperty("variant", "success") @@ -417,7 +421,7 @@ def add_kwargs_boxes(self, groups: list): position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0) for group in groups: box = ScanGroupBox(box_type="kwargs", config=group) - box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.layout.insertWidget(position + len(self.kwarg_boxes), box) self.kwarg_boxes.append(box) box.setVisible(not self._hide_kwarg_boxes) @@ -430,7 +434,7 @@ def add_arg_group(self, group: dict): """ self.arg_box = ScanGroupBox(box_type="args", config=group) self.arg_box.device_selected.connect(self.emit_device_selected) - self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box) self.arg_box.setVisible(not self._hide_arg_box) diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 6ad4c93ff..aa37cc70c 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -209,7 +209,7 @@ def format_item(self, content: str, status=False) -> QTableWidgetItem: if not content or not isinstance(content, str): content = "" item = QTableWidgetItem(content) - item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter) # item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if status: From 5c18b291b59a35d0986557523f6572a7af56949f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 21 Nov 2025 13:47:28 +0100 Subject: [PATCH 042/155] feat(advanced_dock_area): floating docks restore with relative geometry --- .../advanced_dock_area/advanced_dock_area.py | 38 ++--- .../advanced_dock_area/basic_dock_area.py | 158 +++++++++++++++++- .../advanced_dock_area/profile_utils.py | 87 +++++++++- tests/unit_tests/test_advanced_dock_area.py | 79 +++++++++ 4 files changed, 338 insertions(+), 24 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 83b88a7e2..2e4bce02e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -551,6 +551,13 @@ def _profile_exists(self, name: str, namespace: str | None) -> bool: ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: + """ + Write the current workspace snapshot to the provided settings object. + + Args: + settings(QSettings): The settings object to write to. + save_preview(bool): Whether to save a screenshot preview. + """ self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) write_manifest(settings, self.dock_list()) @@ -688,11 +695,20 @@ def load_profile(self, name: str | None = None): if obj_name not in self.widget_map(): w = widget_handler.create_widget(widget_type=widget_class, parent=self) w.setObjectName(obj_name) + floating_state = None + if item.get("floating"): + floating_state = { + "relative": item.get("floating_relative"), + "absolute": item.get("floating_absolute"), + "screen_name": item.get("floating_screen"), + } self._make_dock( w, closable=item["closable"], floatable=item["floatable"], movable=item["movable"], + start_floating=item.get("floating", False), + floating_state=floating_state, area=QtAds.DockWidgetArea.RightDockWidgetArea, ) @@ -925,30 +941,14 @@ def cleanup(self): if __name__ == "__main__": # pragma: no cover import sys - from qtpy.QtWidgets import QTabWidget - app = QApplication(sys.argv) apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - central = QWidget() - layout = QVBoxLayout(central) - window.setCentralWidget(central) - - # two dock areas stacked vertically no instance ids - ads = AdvancedDockArea(mode="creator", enable_profile_management=True) - ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True) - layout.addWidget(ads, 1) - layout.addWidget(ads2, 1) - - # two dock areas inside a tab widget - tabs = QTabWidget(parent=central) - ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3") - ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4") - tabs.addTab(ads3, "Workspace 3") - tabs.addTab(ads4, "Workspace 4") - layout.addWidget(tabs, 1) + ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True) + + window.setCentralWidget(ads) window.show() window.resize(800, 1000) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 864d56942..5c4ec573e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -4,10 +4,11 @@ from dataclasses import dataclass from typing import Any, Callable, Literal, Mapping, Sequence, cast +from bec_lib import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer from qtpy.QtGui import QIcon -from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from shiboken6 import isValid import bec_widgets.widgets.containers.qt_ads as QtAds @@ -22,6 +23,8 @@ CDockWidget, ) +logger = bec_logger.logger + class DockSettingsDialog(QDialog): """Generic settings editor shown from dock title bar actions.""" @@ -64,6 +67,7 @@ class DockCreationSpec: floatable: bool = True movable: bool = True start_floating: bool = False + floating_state: Mapping[str, Any] | None = None area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea on_close: Callable[[CDockWidget, QWidget], None] | None = None tab_with: CDockWidget | None = None @@ -258,6 +262,7 @@ def _make_dock( movable: bool = True, area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, on_close: Callable[[CDockWidget, QWidget], None] | None = None, tab_with: CDockWidget | None = None, relative_to: CDockWidget | None = None, @@ -276,6 +281,7 @@ def _make_dock( movable(bool): Whether the dock can be moved. area(QtAds.DockWidgetArea): Target dock area. start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional geometry metadata to apply when floating. on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. tab_with(CDockWidget | None): Optional dock to tab with. relative_to(CDockWidget | None): Optional dock to position relative to. @@ -336,6 +342,8 @@ def on_widget_destroyed(): if start_floating and tab_with is None and not promote_central: dock.setFloating() + if floating_state: + self._apply_floating_state_to_dock(dock, floating_state) if resolved_icon is not None: dock.setIcon(resolved_icon) return dock @@ -424,6 +432,7 @@ def _build_creation_spec( floatable: bool, movable: bool, start_floating: bool, + floating_state: Mapping[str, object] | None, where: Literal["left", "right", "top", "bottom"] | None, on_close: Callable[[CDockWidget, QWidget], None] | None, tab_with: CDockWidget | QWidget | str | None, @@ -444,6 +453,7 @@ def _build_creation_spec( floatable(bool): Whether the dock can be floated. movable(bool): Whether the dock can be moved. start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional floating geometry metadata. where(Literal["left", "right", "top", "bottom"] | None): Target dock area. on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. @@ -489,6 +499,7 @@ def _build_creation_spec( floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, area=target_area, on_close=on_close, tab_with=resolved_tab, @@ -517,6 +528,7 @@ def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: closable=spec.closable, floatable=spec.floatable, movable=spec.movable, + floating_state=spec.floating_state, area=spec.area, start_floating=spec.start_floating, on_close=spec.on_close, @@ -824,6 +836,126 @@ def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[st defaults[key] = value return defaults + def _select_screen_for_entry( + self, entry: Mapping[str, object], container: QtAds.CFloatingDockContainer | None + ): + """ + Pick the best target screen for a saved floating container. + + Args: + entry(Mapping[str, object]): Floating window entry. + container(QtAds.CFloatingDockContainer | None): Optional container instance. + """ + screens = QApplication.screens() or [] + try: + name = entry.get("screen_name") or "" + except Exception as exc: + logger.warning(f"Invalid screen_name in floating window entry: {exc}") + name = "" + if name: + for screen in screens: + try: + if screen.name() == name: + return screen + except Exception as exc: + logger.warning(f"Error checking screen name '{name}': {exc}") + continue + if container is not None and hasattr(container, "screen"): + screen = container.screen() + if screen is not None: + return screen + return screens[0] if screens else None + + def _apply_saved_floating_geometry( + self, container: QtAds.CFloatingDockContainer, entry: Mapping[str, object] + ) -> None: + """ + Resize/move a floating container using saved geometry information. + + Args: + container(QtAds.CFloatingDockContainer): Target floating container. + entry(Mapping[str, object]): Floating window entry. + """ + abs_geom = entry.get("absolute") if isinstance(entry, Mapping) else None + if isinstance(abs_geom, Mapping): + try: + x = int(abs_geom.get("x")) + y = int(abs_geom.get("y")) + width = int(abs_geom.get("w")) + height = int(abs_geom.get("h")) + except Exception as exc: + logger.warning(f"Invalid absolute geometry in floating window entry: {exc}") + else: + if width > 0 and height > 0: + container.setGeometry(x, y, max(width, 50), max(height, 50)) + return + + rel = entry.get("relative") if isinstance(entry, Mapping) else None + if not isinstance(rel, Mapping): + return + try: + x_ratio = float(rel.get("x")) + y_ratio = float(rel.get("y")) + w_ratio = float(rel.get("w")) + h_ratio = float(rel.get("h")) + except Exception as exc: + logger.warning(f"Invalid relative geometry in floating window entry: {exc}") + return + + screen = self._select_screen_for_entry(entry, container) + if screen is None: + return + geom = screen.availableGeometry() + screen_w = geom.width() + screen_h = geom.height() + if screen_w <= 0 or screen_h <= 0: + return + + min_w = 120 + min_h = 80 + width = max(min_w, int(round(screen_w * max(w_ratio, 0.05)))) + height = max(min_h, int(round(screen_h * max(h_ratio, 0.05)))) + width = min(width, screen_w) + height = min(height, screen_h) + + x = geom.left() + int(round(screen_w * x_ratio)) + y = geom.top() + int(round(screen_h * y_ratio)) + x = max(geom.left(), min(x, geom.left() + screen_w - width)) + y = max(geom.top(), min(y, geom.top() + screen_h - height)) + + container.setGeometry(x, y, width, height) + + def _apply_floating_state_to_dock( + self, dock: CDockWidget, state: Mapping[str, object], *, attempt: int = 0 + ) -> None: + """ + Apply saved floating geometry to a dock once its container exists. + + Args: + dock(CDockWidget): Target dock widget. + state(Mapping[str, object]): Saved floating state. + attempt(int): Current attempt count for retries. + """ + if state is None: + return + + def schedule(next_attempt: int): + QTimer.singleShot( + 50, lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt) + ) + + container = dock.floatingDockContainer() + if container is None: + if attempt < 10: + schedule(attempt + 1) + return + entry = { + "relative": state.get("relative") if isinstance(state, Mapping) else None, + "absolute": state.get("absolute") if isinstance(state, Mapping) else None, + "screen_name": state.get("screen_name") if isinstance(state, Mapping) else None, + } + self._apply_saved_floating_geometry(container, entry) + def save_to_settings( self, settings: QSettings, @@ -1083,6 +1215,7 @@ def new( floatable: bool = True, movable: bool = True, start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, where: Literal["left", "right", "top", "bottom"] | None = None, on_close: Callable[[CDockWidget, QWidget], None] | None = None, tab_with: CDockWidget | QWidget | str | None = None, @@ -1105,6 +1238,7 @@ def new( floatable(bool): Whether the dock is floatable. movable(bool): Whether the dock is movable. start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when ``relative_to`` is provided without an explicit value). on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). @@ -1148,6 +1282,7 @@ def new( floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, where=where, on_close=on_close, tab_with=tab_with, @@ -1173,6 +1308,7 @@ def _on_name_established(_name: str) -> None: floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, where=where, on_close=on_close, tab_with=tab_with, @@ -1187,13 +1323,29 @@ def _on_name_established(_name: str) -> None: dock = self._create_dock_from_spec(spec) return dock if return_dock else widget + def _iter_all_docks(self) -> list[CDockWidget]: + """Return all docks, including those hosted in floating containers.""" + docks = list(self.dock_manager.dockWidgets()) + seen = {id(d) for d in docks} + for container in self.dock_manager.floatingWidgets(): + if container is None: + continue + for dock in container.dockWidgets(): + if dock is None: + continue + if id(dock) in seen: + continue + docks.append(dock) + seen.add(id(dock)) + return docks + def dock_map(self) -> dict[str, CDockWidget]: """Return the dock widgets map as dictionary with names as keys.""" - return self.dock_manager.dockWidgetsMap() + return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()} def dock_list(self) -> list[CDockWidget]: """Return the list of dock widgets.""" - return self.dock_manager.dockWidgets() + return self._iter_all_docks() def widget_map(self) -> dict[str, QWidget]: """Return a dictionary mapping widget names to their corresponding widgets.""" diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index f09f5c544..87f039686 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -22,6 +22,7 @@ from pydantic import BaseModel, Field from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QApplication from bec_widgets.widgets.containers.qt_ads import CDockWidget @@ -655,8 +656,44 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings(QSettings): Settings object to write to. docks(list[CDockWidget]): List of dock widgets to serialize. """ - settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks)) - for i, dock in enumerate(docks): + + def _floating_snapshot(dock: CDockWidget) -> dict | None: + if not hasattr(dock, "isFloating") or not dock.isFloating(): + return None + container = dock.floatingDockContainer() if hasattr(dock, "floatingDockContainer") else None + if container is None: + return None + geom = container.frameGeometry() + if geom.isNull(): + return None + absolute = {"x": geom.x(), "y": geom.y(), "w": geom.width(), "h": geom.height()} + screen = container.screen() if hasattr(container, "screen") else None + if screen is None: + screen = QApplication.screenAt(geom.center()) if QApplication.instance() else None + screen_name = "" + relative = None + if screen is not None: + if hasattr(screen, "name"): + try: + screen_name = screen.name() + except Exception: + screen_name = "" + avail = screen.availableGeometry() + width = max(1, avail.width()) + height = max(1, avail.height()) + relative = { + "x": (geom.left() - avail.left()) / float(width), + "y": (geom.top() - avail.top()) / float(height), + "w": geom.width() / float(width), + "h": geom.height() / float(height), + } + return {"screen_name": screen_name, "relative": relative, "absolute": absolute} + + ordered_docks = [dock for dock in docks if dock.isFloating()] + [ + dock for dock in docks if not dock.isFloating() + ] + settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(ordered_docks)) + for i, dock in enumerate(ordered_docks): settings.setArrayIndex(i) w = dock.widget() settings.setValue("object_name", w.objectName()) @@ -664,6 +701,32 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings.setValue("closable", getattr(dock, "_default_closable", True)) settings.setValue("floatable", getattr(dock, "_default_floatable", True)) settings.setValue("movable", getattr(dock, "_default_movable", True)) + is_floating = bool(dock.isFloating()) + settings.setValue("floating", is_floating) + if is_floating: + snapshot = _floating_snapshot(dock) + if snapshot: + relative = snapshot.get("relative") or {} + absolute = snapshot.get("absolute") or {} + settings.setValue("floating_screen", snapshot.get("screen_name", "")) + settings.setValue("floating_rel_x", relative.get("x", 0.0)) + settings.setValue("floating_rel_y", relative.get("y", 0.0)) + settings.setValue("floating_rel_w", relative.get("w", 0.0)) + settings.setValue("floating_rel_h", relative.get("h", 0.0)) + settings.setValue("floating_abs_x", absolute.get("x", 0)) + settings.setValue("floating_abs_y", absolute.get("y", 0)) + settings.setValue("floating_abs_w", absolute.get("w", 0)) + settings.setValue("floating_abs_h", absolute.get("h", 0)) + else: + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) settings.endArray() @@ -681,6 +744,22 @@ def read_manifest(settings: QSettings) -> list[dict]: count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) for i in range(count): settings.setArrayIndex(i) + floating = settings.value("floating", False, type=bool) + rel = { + "x": float(settings.value("floating_rel_x", 0.0)), + "y": float(settings.value("floating_rel_y", 0.0)), + "w": float(settings.value("floating_rel_w", 0.0)), + "h": float(settings.value("floating_rel_h", 0.0)), + } + abs_geom = { + "x": int(settings.value("floating_abs_x", 0)), + "y": int(settings.value("floating_abs_y", 0)), + "w": int(settings.value("floating_abs_w", 0)), + "h": int(settings.value("floating_abs_h", 0)), + } + if not floating: + rel = None + abs_geom = None items.append( { "object_name": settings.value("object_name"), @@ -688,6 +767,10 @@ def read_manifest(settings: QSettings) -> list[dict]: "closable": settings.value("closable", type=bool), "floatable": settings.value("floatable", type=bool), "movable": settings.value("movable", type=bool), + "floating": floating, + "floating_screen": settings.value("floating_screen", ""), + "floating_relative": rel, + "floating_absolute": abs_geom, } ) settings.endArray() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 212494b2f..f108d39b5 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -21,6 +21,7 @@ DockSettingsDialog, ) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + SETTINGS_KEYS, default_profile_path, get_profile_info, is_profile_read_only, @@ -249,6 +250,31 @@ def test_attach_all_and_delete_all(self, basic_dock_area): basic_dock_area.delete_all() assert basic_dock_area.dock_list() == [] + def test_manifest_serialization_includes_floating_geometry( + self, basic_dock_area, qtbot, tmp_path + ): + anchored = QWidget(parent=basic_dock_area) + anchored.setObjectName("anchored_widget") + floating = QWidget(parent=basic_dock_area) + floating.setObjectName("floating_widget") + + basic_dock_area.new(anchored, return_dock=True) + dock_floating = basic_dock_area.new(floating, return_dock=True, start_floating=True) + qtbot.waitUntil(lambda: dock_floating.isFloating(), timeout=2000) + + settings_path = tmp_path / "manifest.ini" + settings = QSettings(str(settings_path), QSettings.IniFormat) + write_manifest(settings, basic_dock_area.dock_list()) + settings.sync() + + manifest_entries = read_manifest(settings) + assert len(manifest_entries) == 2 + assert manifest_entries[0]["object_name"] == "floating_widget" + assert manifest_entries[0]["floating"] is True + assert manifest_entries[0]["floating_relative"] is not None + assert manifest_entries[1]["object_name"] == "anchored_widget" + assert manifest_entries[1]["floating"] is False + def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area): weights = {"default": 0.5, "left": 2, "center": 3, "right": 4} @@ -837,6 +863,59 @@ def test_attach_all_action(self, advanced_dock_area, qtbot): final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) assert final_floating <= initial_floating + def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot): + helper = profile_helper(advanced_dock_area) + settings = helper.open_user("floating_profile") + settings.clear() + + settings.setValue("profile/created_at", "2025-11-23T00:00:00Z") + settings.beginWriteArray(SETTINGS_KEYS["manifest"], 2) + + # Floating entry + settings.setArrayIndex(0) + settings.setValue("object_name", "FloatingWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", True) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.1) + settings.setValue("floating_rel_y", 0.1) + settings.setValue("floating_rel_w", 0.2) + settings.setValue("floating_rel_h", 0.2) + settings.setValue("floating_abs_x", 50) + settings.setValue("floating_abs_y", 50) + settings.setValue("floating_abs_w", 200) + settings.setValue("floating_abs_h", 150) + + # Anchored entry + settings.setArrayIndex(1) + settings.setValue("object_name", "EmbeddedWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", False) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) + settings.endArray() + settings.sync() + + advanced_dock_area.delete_all() + advanced_dock_area.load_profile("floating_profile") + + qtbot.waitUntil(lambda: "FloatingWaveform" in advanced_dock_area.dock_map(), timeout=3000) + floating_dock = advanced_dock_area.dock_map()["FloatingWaveform"] + assert floating_dock.isFloating() + def test_screenshot_action(self, advanced_dock_area, tmpdir): """Test screenshot toolbar action.""" # Create a test screenshot file path in tmpdir From a00024c66ff11baca0450ebe01eff070d9d41784 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 26 Nov 2025 17:08:26 +0100 Subject: [PATCH 043/155] fix(widget_state_manager): PROPERTIES_TO_SKIP are not restored even if in ini file --- bec_widgets/utils/widget_state_manager.py | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index ae5632b86..13505087b 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -21,7 +21,17 @@ logger = bec_logger.logger -PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"] +PROPERTY_TO_SKIP = [ + "palette", + "font", + "windowIcon", + "windowIconText", + "locale", + "styleSheet", + "updatesEnabled", + "objectName", + "visible", +] class WidgetStateManager: @@ -110,16 +120,8 @@ def _save_widget_state_qsettings( prop = meta.property(i) name = prop.name() - # Skip persisting QWidget visibility because container widgets (e.g. tab - # stacks, dock managers) manage that state themselves. Restoring a saved - # False can permanently hide a widget, while forcing True makes hidden - # tabs show on top. Leave the property to the parent widget instead. - if name == "visible": - continue - if ( - name == "objectName" - or name in PROPERTY_TO_SKIP + name in PROPERTY_TO_SKIP or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter @@ -176,7 +178,7 @@ def _load_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() - if name == "visible": + if name in PROPERTY_TO_SKIP: continue if settings.contains(name): value = settings.value(name) From afc8c4733ec057cf1e142d399b314823f33c4557 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 28 Nov 2025 14:10:51 +0100 Subject: [PATCH 044/155] fix: don't wait forever --- bec_widgets/cli/client_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index bdcbd05ff..622becc6c 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -392,7 +392,8 @@ def _gui_post_startup(self): timeout = 60 # Wait for 'bec' gui to be registered, this may take some time # After 60s timeout. Should this raise an exception on timeout? - while time.time() < time.time() + timeout: + start = time.monotonic() + while time.monotonic() < start + timeout: if len(list(self._server_registry.keys())) < 2 or not hasattr( self, self._anchor_widget ): From caa4e449e47b2e9477e1336888a31a5cbcbc43a7 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 27 Nov 2025 15:43:47 +0100 Subject: [PATCH 045/155] fix(motor_map): x/y motor are saved in properties --- .../widgets/plots/motor_map/motor_map.py | 147 ++++++++++++++---- .../toolbar_components/motor_selection.py | 108 ++++++++++--- tests/unit_tests/test_motor_map_next_gen.py | 65 +++++++- 3 files changed, 264 insertions(+), 56 deletions(-) diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index 61a4764c8..7073bedc4 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -17,7 +17,9 @@ from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import ( - MotorSelectionAction, + MotorSelection, + MotorSelectionConnection, + motor_selection_bundle, ) from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode @@ -107,6 +109,10 @@ class MotorMap(PlotBase): "map", "reset_history", "get_data", + "x_motor", + "x_motor.setter", + "y_motor", + "y_motor.setter", ] update_signal = Signal() @@ -155,11 +161,10 @@ def _init_motor_map_toolbar(self): """ Initialize the toolbar for the motor map widget. """ - motor_selection = MotorSelectionAction(parent=self) - self.toolbar.add_action("motor_selection", motor_selection) - - motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed) - motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed) + self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self) + ) self.toolbar.components.get_action("reset_legend").action.setVisible(False) @@ -188,12 +193,19 @@ def _init_motor_map_toolbar(self): if self.ui_mode == UIMode.POPUP: bundles.append("axis_popup") self.toolbar.show_bundles(bundles) + self._sync_motor_map_selection_toolbar() @SafeSlot() def on_motor_selection_changed(self, _): - action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection") - motor_x = action.motor_x.currentText() - motor_y = action.motor_y.currentText() + action = self.toolbar.components.get_action("motor_selection") + motor_selection: MotorSelection = action.widget + motor_x = motor_selection.motor_x.currentText() + motor_y = motor_selection.motor_y.currentText() + + if motor_x and not self._validate_motor_name(motor_x): + return + if motor_y and not self._validate_motor_name(motor_y): + return if motor_x != "" and motor_y != "": if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name: @@ -246,6 +258,36 @@ def _motor_map_settings_closed(self): # Widget Specific Properties ################################################################################ + @SafeProperty(str) + def x_motor(self) -> str: + """Name of the motor shown on the X axis.""" + return self.config.x_motor.name or "" + + @x_motor.setter + def x_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.x_motor.name or ""): + return + if motor_name and self.y_motor: + self.map(motor_name, self.y_motor, suppress_errors=True) + return + self._set_motor_name(axis="x", motor_name=motor_name) + + @SafeProperty(str) + def y_motor(self) -> str: + """Name of the motor shown on the Y axis.""" + return self.config.y_motor.name or "" + + @y_motor.setter + def y_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.y_motor.name or ""): + return + if motor_name and self.x_motor: + self.map(self.x_motor, motor_name, suppress_errors=True) + return + self._set_motor_name(axis="y", motor_name=motor_name) + # color_scatter for designer, color for CLI to not bother users with QColor @SafeProperty("QColor") def color_scatter(self) -> QtGui.QColor: @@ -387,11 +429,47 @@ def scatter_size(self, scatter_size: int) -> None: self.update_signal.emit() self.property_changed.emit("scatter_size", scatter_size) + def _validate_motor_name(self, motor_name: str) -> bool: + """ + Check motor validity against BEC without raising. + + Args: + motor_name(str): Name of the motor to validate. + + Returns: + bool: True if motor is valid, False otherwise. + """ + if not motor_name: + return False + try: + self.entry_validator.validate_signal(motor_name, None) + return True + except Exception: # noqa: BLE001 - validator can raise multiple error types + return False + + def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None: + """ + Update stored motor name for given axis and optionally refresh the toolbar selection. + """ + motor_name = motor_name or "" + motor_config = self.config.x_motor if axis == "x" else self.config.y_motor + + if motor_config.name == motor_name: + return + + motor_config.name = motor_name + self.property_changed.emit(f"{axis}_motor", motor_name) + + if sync_toolbar: + self._sync_motor_map_selection_toolbar() + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot() - def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: + def map( + self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False + ) -> None: """ Set the x and y motor names. @@ -399,15 +477,23 @@ def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ self.plot_item.clear() if validate_bec: - self.entry_validator.validate_signal(x_name, None) - self.entry_validator.validate_signal(y_name, None) - - self.config.x_motor.name = x_name - self.config.y_motor.name = y_name + if suppress_errors: + try: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + except Exception: + return + else: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + + self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False) + self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False) motor_x_limit = self._get_motor_limit(self.config.x_motor.name) motor_y_limit = self._get_motor_limit(self.config.y_motor.name) @@ -734,21 +820,24 @@ def _sync_motor_map_selection_toolbar(self): """ Sync the motor map selection toolbar with the current motor map. """ - motor_selection = self.toolbar.components.get_action("motor_selection") - - motor_x = motor_selection.motor_x.currentText() - motor_y = motor_selection.motor_y.currentText() + try: + motor_selection_action = self.toolbar.components.get_action("motor_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.") + return + if motor_selection_action is None: + return + motor_selection: MotorSelection = motor_selection_action.widget + target_x = self.config.x_motor.name or "" + target_y = self.config.y_motor.name or "" + + if ( + motor_selection.motor_x.currentText() == target_x + and motor_selection.motor_y.currentText() == target_y + ): + return - if motor_x != self.config.x_motor.name: - motor_selection.motor_x.blockSignals(True) - motor_selection.motor_x.set_device(self.config.x_motor.name) - motor_selection.motor_x.check_validity(self.config.x_motor.name) - motor_selection.motor_x.blockSignals(False) - if motor_y != self.config.y_motor.name: - motor_selection.motor_y.blockSignals(True) - motor_selection.motor_y.set_device(self.config.y_motor.name) - motor_selection.motor_y.check_validity(self.config.y_motor.name) - motor_selection.motor_y.blockSignals(False) + motor_selection.set_motors(target_x, target_y) ################################################################################ # Export Methods diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index a37c3f210..2307fa765 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -1,43 +1,55 @@ -from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget +from qtpy.QtWidgets import QHBoxLayout, QWidget -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox -class MotorSelectionAction(ToolBarAction): +class MotorSelection(QWidget): def __init__(self, parent=None): - super().__init__(icon_path=None, tooltip=None, checkable=False) - self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + super().__init__(parent=parent) + + self.motor_x = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_x.addItem("", None) self.motor_x.setCurrentText("") self.motor_x.setToolTip("Select Motor X") self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) - self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_x.setEditable(True) + self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_y.addItem("", None) self.motor_y.setCurrentText("") self.motor_y.setToolTip("Select Motor Y") self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) + self.motor_y.setEditable(True) - self.container = QWidget(parent) - layout = QHBoxLayout(self.container) + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.motor_x) layout.addWidget(self.motor_y) - self.container.setLayout(layout) - self.action = self.container - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the widget to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the widget to. - target (QWidget): The target widget for the action. - """ - toolbar.addWidget(self.container) + def set_motors(self, motor_x: str | None, motor_y: str | None) -> None: + """Set the displayed motors without emitting selection signals.""" + motor_x = motor_x or "" + motor_y = motor_y or "" + self.motor_x.blockSignals(True) + self.motor_y.blockSignals(True) + try: + if motor_x: + self.motor_x.set_device(motor_x) + self.motor_x.check_validity(motor_x) + else: + self.motor_x.setCurrentText("") + if motor_y: + self.motor_y.set_device(motor_y) + self.motor_y.check_validity(motor_y) + else: + self.motor_y.setCurrentText("") + finally: + self.motor_x.blockSignals(False) + self.motor_y.blockSignals(False) def cleanup(self): """ @@ -47,5 +59,57 @@ def cleanup(self): self.motor_x.deleteLater() self.motor_y.close() self.motor_y.deleteLater() - self.container.close() - self.container.deleteLater() + + +def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for MotorMap. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + + motor_selection_widget = MotorSelection(parent=components.toolbar) + components.add_safe( + "motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("motor_selection", components) + bundle.add_action("motor_selection") + return bundle + + +class MotorSelectionConnection(BundleConnection): + """ + Connection helper for the motor selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "motor_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + + def _widget(self) -> MotorSelection: + return self.components.get_action("motor_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + self._connected = False + widget.cleanup() diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py index 277b3be17..4e296f63d 100644 --- a/tests/unit_tests/test_motor_map_next_gen.py +++ b/tests/unit_tests/test_motor_map_next_gen.py @@ -1,5 +1,4 @@ -import numpy as np -import pyqtgraph as pg +from qtpy.QtTest import QSignalSpy from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from tests.unit_tests.client_mocks import mocked_client @@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client): # Verify toolbar bundle was created during initialization motor_selection = mm.toolbar.components.get_action("motor_selection") - motor_selection.motor_x.setCurrentText("samx") - motor_selection.motor_y.setCurrentText("samy") + motor_selection.widget.motor_x.setCurrentText("samx") + motor_selection.widget.motor_y.setCurrentText("samy") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samy" - motor_selection.motor_y.setCurrentText("samz") + motor_selection.widget.motor_y.setCurrentText("samz") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samz" +def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client): + """Ensure set_motors updates both comboboxes without emitting change signals.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged) + spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged) + + motor_selection.set_motors("samx", "samy") + + assert motor_selection.motor_x.currentText() == "samx" + assert motor_selection.motor_y.currentText() == "samy" + assert spy_x.count() == 0 + assert spy_y.count() == 0 + + +def test_motor_properties_partial_then_complete_map(qtbot, mocked_client): + """Setting x then y via properties should map once both are valid.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + + spy = QSignalSpy(mm.property_changed) + mm.x_motor = "samx" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name is None + assert mm._trace is None # map not triggered yet + assert spy.at(0) == ["x_motor", "samx"] + + mm.y_motor = "samy" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name == "samy" + assert mm._trace is not None # map called once both valid + assert spy.at(1) == ["y_motor", "samy"] + assert len(mm._buffer["x"]) == 1 + assert len(mm._buffer["y"]) == 1 + + +def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client): + """_set_motor_name should emit property changes and sync toolbar widgets.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy = QSignalSpy(mm.property_changed) + mm._set_motor_name("x", "samx") + + assert mm.config.x_motor.name == "samx" + assert motor_selection.motor_x.currentText() == "samx" + assert spy.at(0) == ["x_motor", "samx"] + + # Calling with same name should be a no-op + initial_count = spy.count() + mm._set_motor_name("x", "samx") + assert spy.count() == initial_count + + def test_motor_map_settings_dialog(qtbot, mocked_client): """Test the settings dialog for the motor map.""" mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True) From c354a9b24928e2b838d0a4da684f748b89b88c02 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 26 Nov 2025 21:32:31 +0100 Subject: [PATCH 046/155] fix(heatmap): interpolation of the image moved to separate thread --- bec_widgets/widgets/plots/heatmap/heatmap.py | 342 +++++++++++++++++-- tests/unit_tests/test_heatmap_widget.py | 117 ++++++- 2 files changed, 431 insertions(+), 28 deletions(-) diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 05501b9f0..bb43852a6 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from dataclasses import dataclass from typing import Literal import numpy as np @@ -8,7 +9,7 @@ from bec_lib import bec_logger, messages from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import QTimer, Signal +from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal from qtpy.QtGui import QTransform from scipy.interpolate import ( CloughTocher2DInterpolator, @@ -78,6 +79,85 @@ class HeatmapConfig(ConnectionConfig): _validate_color_palette = field_validator("color_map")(Colors.validate_color_map) +@dataclass +class _InterpolationRequest: + x_data: list[float] + y_data: list[float] + z_data: list[float] + scan_id: str + interpolation: str + oversampling_factor: float + + +class _StepInterpolationWorker(QObject): + """Worker for performing step-scan interpolation in a background thread. + + This worker computes the interpolated heatmap image using the provided data + and settings, then emits the result or a failure signal. + + Args: + x_data (list[float] or np.ndarray): The x-coordinates of the data points. + y_data (list[float] or np.ndarray): The y-coordinates of the data points. + z_data (list[float] or np.ndarray): The z-values (intensity) of the data points. + interpolation (str): The interpolation method to use. + oversampling_factor (float): The oversampling factor for the interpolation grid. + generation (int): The generation number for tracking requests. + scan_id (str): The scan identifier. + parent (QObject | None, optional): The parent QObject. Defaults to None. + + Signals: + finished(image, transform, generation, scan_id): + Emitted when interpolation is successful. + - image: The resulting image (numpy array or similar). + - transform: The QTransform for the image. + - generation: The generation number. + - scan_id: The scan identifier. + failed(error_message, generation, scan_id): + Emitted when interpolation fails. + - error_message: The error message string. + - generation: The generation number. + - scan_id: The scan identifier. + """ + + finished = Signal(object, object, int, str) + failed = Signal(str, int, str) + + def __init__( + self, + x_data: list[float] | np.ndarray, + y_data: list[float] | np.ndarray, + z_data: list[float] | np.ndarray, + interpolation: str, + oversampling_factor: float, + generation: int, + scan_id: str, + parent: QObject | None = None, + ): + super().__init__(parent=parent) + self._x_data = np.asarray(x_data, dtype=float) + self._y_data = np.asarray(y_data, dtype=float) + self._z_data = np.asarray(z_data, dtype=float) + self._interpolation = interpolation + self._oversampling_factor = oversampling_factor + self._generation = generation + self._scan_id = scan_id + + def run(self): + try: + image, transform = Heatmap.compute_step_scan_image( + x_data=self._x_data, + y_data=self._y_data, + z_data=self._z_data, + oversampling_factor=self._oversampling_factor, + interpolation_method=self._interpolation, + ) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Step-scan interpolation failed with: {exc}") + self.failed.emit(str(exc), self._generation, self._scan_id) + return + self.finished.emit(image, transform, self._generation, self._scan_id) + + class Heatmap(ImageBase): """ Heatmap widget for visualizing 2d grid data with color mapping for the z-axis. @@ -150,6 +230,10 @@ def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs): self.scan_item = None self.status_message = None self._grid_index = None + self._interpolation_generation = 0 + self._interpolation_thread: QThread | None = None + self._interpolation_worker: _StepInterpolationWorker | None = None + self._pending_interpolation_request: _InterpolationRequest | None = None self.heatmap_dialog = None bg_color = pg.mkColor((240, 240, 240, 150)) self.config_label = pg.LegendItem( @@ -426,6 +510,7 @@ def on_scan_status(self, msg: dict, meta: dict): if current_scan_id is None: return if current_scan_id != self.scan_id: + self._invalidate_interpolation_generation() self.reset() self.new_scan.emit() self.new_scan_id.emit(current_scan_id) @@ -531,13 +616,38 @@ def update_plot(self, _=None) -> None: if self._image_config.show_config_label: self.redraw_config_label() - img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data) - if img is None: + if self._is_grid_scan_supported(scan_msg): + img, transform = self.get_grid_scan_image(z_data, scan_msg) + self._apply_image_update(img, transform) + return + + if len(z_data) < 4: + # LinearNDInterpolator requires at least 4 points to interpolate + logger.warning("Not enough data points to interpolate; skipping update.") + return + + self._request_step_scan_interpolation(x_data, y_data, z_data, scan_msg) + + def _apply_image_update(self, img: np.ndarray | None, transform: QTransform | None): + """Apply interpolated image and transform to the heatmap display. + + This method updates the main image with the computed data and emits + the image_updated signal. Color bar signals are temporarily blocked + during the update to prevent cascading updates. + + Args: + img(np.ndarray): The interpolated image data, or None if unavailable + transform(QTransform): QTransform mapping pixel to world coordinates, or None if unavailable + """ + if img is None or transform is None: logger.warning("Image data is None; skipping update.") return if self._color_bar is not None: self._color_bar.blockSignals(True) + if self.main_image is None: + logger.warning("Main image item is None; cannot update image.") + return self.main_image.set_data(img, transform=transform) if self._color_bar is not None: self._color_bar.blockSignals(False) @@ -545,6 +655,122 @@ def update_plot(self, _=None) -> None: if self.crosshair is not None: self.crosshair.update_markers_on_image_change() + def _request_step_scan_interpolation( + self, + x_data: list[float], + y_data: list[float], + z_data: list[float], + msg: messages.ScanStatusMessage, + ): + """Request step-scan interpolation in a background thread. + + If a thread is already running, the request is queued as a pending request + and will be processed when the current interpolation completes. + + Args: + x_data(list[float]): X coordinates of data points + y_data(list[float]): Y coordinates of data points + z_data(list[float]): Z values at each point + msg(messages.ScanStatusMessage): Scan status message containing scan metadata + """ + request = _InterpolationRequest( + x_data=list(x_data), + y_data=list(y_data), + z_data=list(z_data), + scan_id=msg.scan_id, + interpolation=self._image_config.interpolation, + oversampling_factor=self._image_config.oversampling_factor, + ) + + if self._interpolation_thread is not None: + self._pending_interpolation_request = request + return + + self._start_step_scan_interpolation(request) + + def _start_step_scan_interpolation(self, request: _InterpolationRequest): + self._interpolation_generation += 1 + generation = self._interpolation_generation + self._interpolation_thread = QThread() + self._interpolation_worker = _StepInterpolationWorker( + x_data=request.x_data, + y_data=request.y_data, + z_data=request.z_data, + interpolation=request.interpolation, + oversampling_factor=request.oversampling_factor, + generation=generation, + scan_id=request.scan_id, + ) + self._interpolation_worker.moveToThread(self._interpolation_thread) + self._interpolation_thread.started.connect( + self._interpolation_worker.run, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.finished.connect( + self._on_interpolation_finished, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.failed.connect( + self._on_interpolation_failed, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_thread.start() + + def _on_interpolation_finished( + self, img: np.ndarray, transform: QTransform, generation: int, scan_id: str + ): + if generation == self._interpolation_generation and scan_id == self.scan_id: + self._apply_image_update(img, transform) + else: + logger.debug("Discarding outdated interpolation result.") + self._finish_interpolation_thread() + self._maybe_start_pending_interpolation() + + def _on_interpolation_failed(self, error: str, generation: int, scan_id: str): + logger.warning( + "Interpolation failed for scan %s (generation %s): %s", scan_id, generation, error + ) + self._finish_interpolation_thread() + self._maybe_start_pending_interpolation() + + def _finish_interpolation_thread(self): + if self._interpolation_worker is not None: + self._interpolation_worker.deleteLater() + self._interpolation_worker = None + if self._interpolation_thread is not None: + self._interpolation_thread.quit() + self._interpolation_thread.wait() + self._interpolation_thread.deleteLater() + self._interpolation_thread = None + + def _maybe_start_pending_interpolation(self): + if self._pending_interpolation_request is None: + return + if self._pending_interpolation_request.scan_id != self.scan_id: + self._pending_interpolation_request = None + return + + pending = self._pending_interpolation_request + self._pending_interpolation_request = None + self._start_step_scan_interpolation(pending) + + def _cancel_interpolation(self): + """Cancel any pending interpolation request without invalidating in-flight work. + + This clears the pending request queue but does not increment the generation + counter, allowing any currently running interpolation to complete and update + the display if it matches the current scan. + """ + self._pending_interpolation_request = None + # Do not bump generation so an in-flight worker can still deliver the latest scan image. + + def _invalidate_interpolation_generation(self): + """Invalidate all in-flight and pending interpolation results. + + Increments the generation counter so that any currently running or + queued interpolation work will be discarded when it completes. + This is typically called when starting a new scan. + """ + # Bump the generation so any in-flight worker results are ignored. + self._interpolation_generation += 1 + def redraw_config_label(self): scan_msg = self.status_message if scan_msg is None: @@ -590,21 +816,35 @@ def get_image_data( logger.warning("x, y, or z data is None; skipping update.") return None, None - if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation: - # We only support the grid scan mode if both scanning motors - # are configured in the heatmap config. - device_x = self._image_config.x_device.entry - device_y = self._image_config.y_device.entry - if ( - device_x in msg.request_inputs["arg_bundle"] - and device_y in msg.request_inputs["arg_bundle"] - ): - return self.get_grid_scan_image(z_data, msg) + if self._is_grid_scan_supported(msg): + return self.get_grid_scan_image(z_data, msg) if len(z_data) < 4: # LinearNDInterpolator requires at least 4 points to interpolate return None, None return self.get_step_scan_image(x_data, y_data, z_data, msg) + def _is_grid_scan_supported(self, msg: messages.ScanStatusMessage) -> bool: + """Check if the scan can use optimized grid_scan rendering. + + Grid scans can avoid interpolation if both X and Y devices match + the configured devices and interpolation is not enforced. + + Args: + msg(messages.ScanStatusMessage): Scan status message containing scan metadata + + Returns: + True if grid_scan optimization is applicable, False otherwise + """ + if msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation: + return False + + device_x = self._image_config.x_device.entry + device_y = self._image_config.y_device.entry + return ( + device_x in msg.request_inputs["arg_bundle"] + and device_y in msg.request_inputs["arg_bundle"] + ) + def get_grid_scan_image( self, z_data: list[float], msg: messages.ScanStatusMessage ) -> tuple[np.ndarray, QTransform]: @@ -704,17 +944,49 @@ def get_step_scan_image( Returns: tuple[np.ndarray, QTransform]: The image data and the QTransform. """ + return self.compute_step_scan_image( + x_data=x_data, + y_data=y_data, + z_data=z_data, + oversampling_factor=self._image_config.oversampling_factor, + interpolation_method=self._image_config.interpolation, + ) + + @staticmethod + def compute_step_scan_image( + x_data: list[float] | np.ndarray, + y_data: list[float] | np.ndarray, + z_data: list[float] | np.ndarray, + oversampling_factor: float, + interpolation_method: str, + ) -> tuple[np.ndarray, QTransform]: + """Compute interpolated heatmap image from step-scan data. + + This static method is suitable for execution in a background thread + as it doesn't access any instance state. + + Args: + x_data(list[float]): X coordinates of data points + y_data(list[float]): Y coordinates of data points + z_data(list[float]): Z values at each point + oversampling_factor(float): Grid resolution multiplier (>1.0 for higher resolution) + interpolation_method(str): One of 'linear', 'nearest', or 'clough' + + Returns: + (tuple[np.ndarray, QTransform]):Tuple of (interpolated_grid, transform) where transform maps pixel to world coordinates + """ xy_data = np.column_stack((x_data, y_data)) - grid_x, grid_y, transform = self.get_image_grid(xy_data) + grid_x, grid_y, transform = Heatmap.build_image_grid( + positions=xy_data, oversampling_factor=oversampling_factor + ) - # Interpolate the z data onto the grid - if self._image_config.interpolation == "linear": + if interpolation_method == "linear": interp = LinearNDInterpolator(xy_data, z_data) - elif self._image_config.interpolation == "nearest": + elif interpolation_method == "nearest": interp = NearestNDInterpolator(xy_data, z_data) - elif self._image_config.interpolation == "clough": + elif interpolation_method == "clough": interp = CloughTocher2DInterpolator(xy_data, z_data) - else: + else: # pragma: no cover - guarded by validation raise ValueError( "Interpolation method must be either 'linear', 'nearest', or 'clough'." ) @@ -733,22 +1005,33 @@ def get_image_grid(self, positions) -> tuple[np.ndarray, np.ndarray, QTransform] Returns: tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform. """ - base_width, base_height = self.estimate_image_resolution(positions) + return self.build_image_grid( + positions=positions, oversampling_factor=self._image_config.oversampling_factor + ) + + @staticmethod + def build_image_grid( + positions: np.ndarray, oversampling_factor: float + ) -> tuple[np.ndarray, np.ndarray, QTransform]: + """Build an interpolation grid covering the data positions. - # Apply oversampling factor - factor = self._image_config.oversampling_factor + Args: + positions: (N, 2) array of (x, y) coordinates + oversampling_factor: Grid resolution multiplier (>1.0 for higher resolution) - # Apply oversampling - width = int(base_width * factor) - height = int(base_height * factor) + Returns: + Tuple of (grid_x, grid_y, transform) where grid_x/grid_y are meshgrids + for interpolation and transform maps pixel to world coordinates + """ + base_width, base_height = Heatmap.estimate_image_resolution(positions) + width = max(1, int(base_width * oversampling_factor)) + height = max(1, int(base_height * oversampling_factor)) - # Create grid grid_x, grid_y = np.mgrid[ min(positions[:, 0]) : max(positions[:, 0]) : width * 1j, min(positions[:, 1]) : max(positions[:, 1]) : height * 1j, ] - # Calculate transform x_min, x_max = min(positions[:, 0]), max(positions[:, 0]) y_min, y_max = min(positions[:, 1]), max(positions[:, 1]) x_range = x_max - x_min @@ -832,6 +1115,7 @@ def _fetch_scan_data_and_access(self): return scan_devices, "value" def reset(self): + self._cancel_interpolation() self._grid_index = None self.main_image.clear() if self.crosshair is not None: @@ -966,6 +1250,10 @@ def transpose(self, enable: bool): """ self.main_image.transpose = enable + def cleanup(self): + self._finish_interpolation_thread() + super().cleanup() + if __name__ == "__main__": # pragma: no cover import sys diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index ff2d42741..7cf83251c 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -4,9 +4,15 @@ import pytest from bec_lib import messages from bec_lib.scan_history import ScanHistory +from qtpy.QtGui import QTransform from qtpy.QtCore import QPointF -from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal +from bec_widgets.widgets.plots.heatmap.heatmap import ( + Heatmap, + HeatmapConfig, + HeatmapDeviceSignal, + _StepInterpolationWorker, +) # pytest: disable=unused-import from tests.unit_tests.client_mocks import mocked_client @@ -448,12 +454,16 @@ def test_heatmap_widget_reset(heatmap_widget): """ Test that the reset method clears the plot. """ + heatmap_widget._pending_interpolation_request = object() + heatmap_widget._interpolation_generation = 5 heatmap_widget.scan_item = create_dummy_scan_item() heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") heatmap_widget.reset() assert heatmap_widget._grid_index is None assert heatmap_widget.main_image.raw_data is None + assert heatmap_widget._pending_interpolation_request is None + assert heatmap_widget._interpolation_generation == 5 def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot): @@ -478,3 +488,108 @@ def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_ heatmap_widget.enforce_interpolation = True heatmap_widget.oversampling_factor = 2.0 qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (20, 20)) + + +def test_step_interpolation_worker_emits_finished(qtbot): + worker = _StepInterpolationWorker( + x_data=[0.0, 1.0, 0.5, 0.2], + y_data=[0.0, 0.0, 1.0, 1.0], + z_data=[1.0, 2.0, 3.0, 4.0], + interpolation="linear", + oversampling_factor=1.0, + generation=1, + scan_id="scan-1", + ) + with qtbot.waitSignal(worker.finished, timeout=1000) as blocker: + worker.run() + img, transform, generation, scan_id = blocker.args + assert img.shape[0] > 0 + assert isinstance(transform, QTransform) + assert generation == 1 + assert scan_id == "scan-1" + + +def test_step_interpolation_worker_emits_failed(qtbot, monkeypatch): + def _scan_goes_boom(**kwargs): + raise RuntimeError("crash") + + monkeypatch.setattr( + "bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.compute_step_scan_image", _scan_goes_boom + ) + worker = _StepInterpolationWorker( + x_data=[0.0, 1.0, 0.5, 0.2], + y_data=[0.0, 0.0, 1.0, 1.0], + z_data=[1.0, 2.0, 3.0, 4.0], + interpolation="linear", + oversampling_factor=1.0, + generation=99, + scan_id="scan-err", + ) + with qtbot.waitSignal(worker.failed, timeout=1000) as blocker: + worker.run() + error, generation, scan_id = blocker.args + assert "crash" in error + assert generation == 99 + assert scan_id == "scan-err" + + +def test_interpolation_generation_invalidation(heatmap_widget): + heatmap_widget.scan_id = "scan-1" + heatmap_widget._interpolation_generation = 2 + with ( + mock.patch.object(heatmap_widget, "_apply_image_update") as apply_mock, + mock.patch.object(heatmap_widget, "_finish_interpolation_thread") as finish_mock, + mock.patch.object(heatmap_widget, "_maybe_start_pending_interpolation") as maybe_mock, + ): + heatmap_widget._on_interpolation_finished( + np.zeros((2, 2)), QTransform(), generation=1, scan_id="scan-1" + ) + apply_mock.assert_not_called() + finish_mock.assert_called_once() + maybe_mock.assert_called_once() + + +def test_pending_request_queueing_and_start(heatmap_widget): + heatmap_widget.scan_id = "scan-queue" + heatmap_widget.status_message = messages.ScanStatusMessage( + scan_id="scan-queue", + status="open", + scan_name="step_scan", + scan_type="step", + metadata={}, + info={"positions": [[0, 0], [1, 1], [2, 2], [3, 3]]}, + ) + heatmap_widget._interpolation_thread = object() # simulate running thread + + with mock.patch.object(heatmap_widget, "_start_step_scan_interpolation") as start_mock: + heatmap_widget._request_step_scan_interpolation( + x_data=[0, 1, 2, 3], + y_data=[0, 1, 2, 3], + z_data=[0, 1, 2, 3], + msg=heatmap_widget.status_message, + ) + assert heatmap_widget._pending_interpolation_request is not None + + # Now simulate worker finished and thread cleaned up + heatmap_widget._interpolation_thread = None + pending = heatmap_widget._pending_interpolation_request + heatmap_widget._pending_interpolation_request = pending + heatmap_widget._maybe_start_pending_interpolation() + + start_mock.assert_called_once() + + +def test_finish_interpolation_thread_cleans_references(heatmap_widget): + worker_mock = mock.Mock() + thread_mock = mock.Mock() + heatmap_widget._interpolation_worker = worker_mock + heatmap_widget._interpolation_thread = thread_mock + + heatmap_widget._finish_interpolation_thread() + + worker_mock.deleteLater.assert_called_once() + thread_mock.quit.assert_called_once() + thread_mock.wait.assert_called_once() + thread_mock.deleteLater.assert_called_once() + assert heatmap_widget._interpolation_worker is None + assert heatmap_widget._interpolation_thread is None From 063e5d064ca521db74825dd3e71eb91f5e9880d3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 5 Dec 2025 10:58:58 +0100 Subject: [PATCH 047/155] perf(heatmap): thread worker optimization --- bec_widgets/widgets/plots/heatmap/heatmap.py | 185 ++++++++++--------- tests/unit_tests/test_heatmap_widget.py | 45 ++--- 2 files changed, 119 insertions(+), 111 deletions(-) diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index bb43852a6..faf614538 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -81,9 +81,22 @@ class HeatmapConfig(ConnectionConfig): @dataclass class _InterpolationRequest: + """Immutable payload describing an interpolation request for the worker thread. + + Args: + x_data: X coordinates collected so far. + y_data: Y coordinates collected so far. + z_data: Z values associated with x/y. + data_version: Number of points at request time (len(z_data)); used to reject stale results. + scan_id: Identifier for the scan that produced the data. + interpolation: Interpolation method to apply. + oversampling_factor: Oversampling factor for the interpolation grid. + """ + x_data: list[float] y_data: list[float] z_data: list[float] + data_version: int scan_id: str interpolation: str oversampling_factor: float @@ -95,67 +108,50 @@ class _StepInterpolationWorker(QObject): This worker computes the interpolated heatmap image using the provided data and settings, then emits the result or a failure signal. - Args: - x_data (list[float] or np.ndarray): The x-coordinates of the data points. - y_data (list[float] or np.ndarray): The y-coordinates of the data points. - z_data (list[float] or np.ndarray): The z-values (intensity) of the data points. - interpolation (str): The interpolation method to use. - oversampling_factor (float): The oversampling factor for the interpolation grid. - generation (int): The generation number for tracking requests. - scan_id (str): The scan identifier. - parent (QObject | None, optional): The parent QObject. Defaults to None. - Signals: - finished(image, transform, generation, scan_id): + finished(image, transform, data_version, scan_id): Emitted when interpolation is successful. - image: The resulting image (numpy array or similar). - transform: The QTransform for the image. - - generation: The generation number. + - data_version: The data version for the request. - scan_id: The scan identifier. - failed(error_message, generation, scan_id): + failed(error_message, data_version, scan_id): Emitted when interpolation fails. - error_message: The error message string. - - generation: The generation number. + - data_version: The data version for the request. - scan_id: The scan identifier. """ finished = Signal(object, object, int, str) failed = Signal(str, int, str) - def __init__( - self, - x_data: list[float] | np.ndarray, - y_data: list[float] | np.ndarray, - z_data: list[float] | np.ndarray, - interpolation: str, - oversampling_factor: float, - generation: int, - scan_id: str, - parent: QObject | None = None, - ): + def __init__(self, parent: QObject | None = None): super().__init__(parent=parent) - self._x_data = np.asarray(x_data, dtype=float) - self._y_data = np.asarray(y_data, dtype=float) - self._z_data = np.asarray(z_data, dtype=float) - self._interpolation = interpolation - self._oversampling_factor = oversampling_factor - self._generation = generation - self._scan_id = scan_id - - def run(self): + self._active_request: _InterpolationRequest | None = None + + @SafeSlot(object, int) + def process(self, request: _InterpolationRequest, data_version: int): + """ + Process an interpolation request in the worker thread. + + Args: + request(_InterpolationRequest): The interpolation request payload. + data_version(int): The data version for the request. + """ + self._active_request = request try: image, transform = Heatmap.compute_step_scan_image( - x_data=self._x_data, - y_data=self._y_data, - z_data=self._z_data, - oversampling_factor=self._oversampling_factor, - interpolation_method=self._interpolation, + x_data=np.asarray(request.x_data, dtype=float), + y_data=np.asarray(request.y_data, dtype=float), + z_data=np.asarray(request.z_data, dtype=float), + oversampling_factor=request.oversampling_factor, + interpolation_method=request.interpolation, ) except Exception as exc: # pragma: no cover - defensive logger.warning(f"Step-scan interpolation failed with: {exc}") - self.failed.emit(str(exc), self._generation, self._scan_id) + self.failed.emit(str(exc), data_version, request.scan_id) return - self.finished.emit(image, transform, self._generation, self._scan_id) + self.finished.emit(image, transform, data_version, request.scan_id) class Heatmap(ImageBase): @@ -208,6 +204,7 @@ class Heatmap(ImageBase): new_scan_id = Signal(str) sync_signal_update = Signal() heatmap_property_changed = Signal() + interpolation_requested = Signal(object, int) def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs): if config is None: @@ -230,7 +227,9 @@ def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs): self.scan_item = None self.status_message = None self._grid_index = None - self._interpolation_generation = 0 + # Highest data_version we have dispatched for the current scan; used to drop stale results. + # Initialized to -1 so the first real request (len(z_data) >= 0) always supersedes it. + self._latest_interpolation_version = -1 self._interpolation_thread: QThread | None = None self._interpolation_worker: _StepInterpolationWorker | None = None self._pending_interpolation_request: _InterpolationRequest | None = None @@ -510,7 +509,7 @@ def on_scan_status(self, msg: dict, meta: dict): if current_scan_id is None: return if current_scan_id != self.scan_id: - self._invalidate_interpolation_generation() + self._invalidate_interpolation_generation() # Invalidate any pending interpolation work when a new scan starts self.reset() self.new_scan.emit() self.new_scan_id.emit(current_scan_id) @@ -677,66 +676,77 @@ def _request_step_scan_interpolation( x_data=list(x_data), y_data=list(y_data), z_data=list(z_data), + data_version=len(z_data), scan_id=msg.scan_id, interpolation=self._image_config.interpolation, oversampling_factor=self._image_config.oversampling_factor, ) - if self._interpolation_thread is not None: + if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): self._pending_interpolation_request = request return self._start_step_scan_interpolation(request) + def _ensure_interpolation_thread(self): + if self._interpolation_thread is None: + self._interpolation_thread = QThread() + self._interpolation_worker = _StepInterpolationWorker() + self._interpolation_worker.moveToThread(self._interpolation_thread) + self.interpolation_requested.connect( + self._interpolation_worker.process, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.finished.connect( + self._on_interpolation_finished, Qt.ConnectionType.QueuedConnection + ) + self._interpolation_worker.failed.connect( + self._on_interpolation_failed, Qt.ConnectionType.QueuedConnection + ) + if self._interpolation_thread is not None and not self._interpolation_thread.isRunning(): + self._interpolation_thread.start() + def _start_step_scan_interpolation(self, request: _InterpolationRequest): - self._interpolation_generation += 1 - generation = self._interpolation_generation - self._interpolation_thread = QThread() - self._interpolation_worker = _StepInterpolationWorker( - x_data=request.x_data, - y_data=request.y_data, - z_data=request.z_data, - interpolation=request.interpolation, - oversampling_factor=request.oversampling_factor, - generation=generation, - scan_id=request.scan_id, - ) - self._interpolation_worker.moveToThread(self._interpolation_thread) - self._interpolation_thread.started.connect( - self._interpolation_worker.run, Qt.ConnectionType.QueuedConnection - ) - self._interpolation_worker.finished.connect( - self._on_interpolation_finished, Qt.ConnectionType.QueuedConnection - ) - self._interpolation_worker.failed.connect( - self._on_interpolation_failed, Qt.ConnectionType.QueuedConnection - ) - self._interpolation_thread.start() + # data_version = len(z_data) at the time of the request; keep the latest to gate results. + self._ensure_interpolation_thread() + if self._interpolation_thread is not None and not self._interpolation_thread.isRunning(): + self._interpolation_thread.start() + self._latest_interpolation_version = request.data_version + self.interpolation_requested.emit(request, request.data_version) def _on_interpolation_finished( - self, img: np.ndarray, transform: QTransform, generation: int, scan_id: str + self, img: np.ndarray, transform: QTransform, data_version: int, scan_id: str ): - if generation == self._interpolation_generation and scan_id == self.scan_id: + # Only accept results that match the latest dispatched version for the active scan. + if data_version == self._latest_interpolation_version and scan_id == self.scan_id: self._apply_image_update(img, transform) else: - logger.debug("Discarding outdated interpolation result.") - self._finish_interpolation_thread() + logger.info("Discarding outdated interpolation result.") + if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): + self._interpolation_thread.quit() + self._interpolation_thread.wait() self._maybe_start_pending_interpolation() - def _on_interpolation_failed(self, error: str, generation: int, scan_id: str): - logger.warning( - "Interpolation failed for scan %s (generation %s): %s", scan_id, generation, error - ) - self._finish_interpolation_thread() + def _on_interpolation_failed(self, error: str, data_version: int, scan_id: str): + logger.warning(f"Interpolation failed for scan {scan_id} (version {data_version}): {error}") + if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): + self._interpolation_thread.quit() + self._interpolation_thread.wait() self._maybe_start_pending_interpolation() def _finish_interpolation_thread(self): + self._pending_interpolation_request = None if self._interpolation_worker is not None: + try: + self.interpolation_requested.disconnect(self._interpolation_worker.process) + except (TypeError, RuntimeError): + # Defensive: disconnect may fail if already disconnected or during shutdown. + pass self._interpolation_worker.deleteLater() self._interpolation_worker = None if self._interpolation_thread is not None: - self._interpolation_thread.quit() - self._interpolation_thread.wait() + if self._interpolation_thread.isRunning(): + self._interpolation_thread.quit() + self._interpolation_thread.wait() self._interpolation_thread.deleteLater() self._interpolation_thread = None @@ -754,22 +764,17 @@ def _maybe_start_pending_interpolation(self): def _cancel_interpolation(self): """Cancel any pending interpolation request without invalidating in-flight work. - This clears the pending request queue but does not increment the generation - counter, allowing any currently running interpolation to complete and update - the display if it matches the current scan. + This clears the pending request queue but does not invalidate in-flight work, + allowing any currently running interpolation to complete and update the display + if it matches the current scan. """ self._pending_interpolation_request = None - # Do not bump generation so an in-flight worker can still deliver the latest scan image. + # Do not change the active data version so an in-flight worker can still deliver. def _invalidate_interpolation_generation(self): - """Invalidate all in-flight and pending interpolation results. - - Increments the generation counter so that any currently running or - queued interpolation work will be discarded when it completes. - This is typically called when starting a new scan. - """ - # Bump the generation so any in-flight worker results are ignored. - self._interpolation_generation += 1 + """Invalidate all pending interpolation results and ignore in-flight updates.""" + self._pending_interpolation_request = None + self._latest_interpolation_version = -1 def redraw_config_label(self): scan_msg = self.status_message diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 7cf83251c..d3a64da7c 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -11,6 +11,7 @@ Heatmap, HeatmapConfig, HeatmapDeviceSignal, + _InterpolationRequest, _StepInterpolationWorker, ) @@ -455,7 +456,7 @@ def test_heatmap_widget_reset(heatmap_widget): Test that the reset method clears the plot. """ heatmap_widget._pending_interpolation_request = object() - heatmap_widget._interpolation_generation = 5 + heatmap_widget._latest_interpolation_version = 5 heatmap_widget.scan_item = create_dummy_scan_item() heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") @@ -463,7 +464,7 @@ def test_heatmap_widget_reset(heatmap_widget): assert heatmap_widget._grid_index is None assert heatmap_widget.main_image.raw_data is None assert heatmap_widget._pending_interpolation_request is None - assert heatmap_widget._interpolation_generation == 5 + assert heatmap_widget._latest_interpolation_version == 5 def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot): @@ -491,22 +492,23 @@ def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_ def test_step_interpolation_worker_emits_finished(qtbot): - worker = _StepInterpolationWorker( + worker = _StepInterpolationWorker() + request = _InterpolationRequest( x_data=[0.0, 1.0, 0.5, 0.2], y_data=[0.0, 0.0, 1.0, 1.0], z_data=[1.0, 2.0, 3.0, 4.0], + data_version=4, + scan_id="scan-1", interpolation="linear", oversampling_factor=1.0, - generation=1, - scan_id="scan-1", ) with qtbot.waitSignal(worker.finished, timeout=1000) as blocker: - worker.run() - img, transform, generation, scan_id = blocker.args + worker.process(request, request.data_version) + img, transform, data_version, scan_id = blocker.args assert img.shape[0] > 0 assert isinstance(transform, QTransform) - assert generation == 1 - assert scan_id == "scan-1" + assert data_version == request.data_version + assert scan_id == request.scan_id def test_step_interpolation_worker_emits_failed(qtbot, monkeypatch): @@ -516,36 +518,35 @@ def _scan_goes_boom(**kwargs): monkeypatch.setattr( "bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.compute_step_scan_image", _scan_goes_boom ) - worker = _StepInterpolationWorker( + worker = _StepInterpolationWorker() + request = _InterpolationRequest( x_data=[0.0, 1.0, 0.5, 0.2], y_data=[0.0, 0.0, 1.0, 1.0], z_data=[1.0, 2.0, 3.0, 4.0], + data_version=99, + scan_id="scan-err", interpolation="linear", oversampling_factor=1.0, - generation=99, - scan_id="scan-err", ) with qtbot.waitSignal(worker.failed, timeout=1000) as blocker: - worker.run() - error, generation, scan_id = blocker.args + worker.process(request, request.data_version) + error, data_version, scan_id = blocker.args assert "crash" in error - assert generation == 99 - assert scan_id == "scan-err" + assert data_version == request.data_version + assert scan_id == request.scan_id def test_interpolation_generation_invalidation(heatmap_widget): heatmap_widget.scan_id = "scan-1" - heatmap_widget._interpolation_generation = 2 + heatmap_widget._latest_interpolation_version = 2 with ( mock.patch.object(heatmap_widget, "_apply_image_update") as apply_mock, - mock.patch.object(heatmap_widget, "_finish_interpolation_thread") as finish_mock, mock.patch.object(heatmap_widget, "_maybe_start_pending_interpolation") as maybe_mock, ): heatmap_widget._on_interpolation_finished( - np.zeros((2, 2)), QTransform(), generation=1, scan_id="scan-1" + np.zeros((2, 2)), QTransform(), data_version=1, scan_id="scan-1" ) apply_mock.assert_not_called() - finish_mock.assert_called_once() maybe_mock.assert_called_once() @@ -559,7 +560,8 @@ def test_pending_request_queueing_and_start(heatmap_widget): metadata={}, info={"positions": [[0, 0], [1, 1], [2, 2], [3, 3]]}, ) - heatmap_widget._interpolation_thread = object() # simulate running thread + heatmap_widget._interpolation_thread = mock.MagicMock() + heatmap_widget._interpolation_thread.isRunning.return_value = True with mock.patch.object(heatmap_widget, "_start_step_scan_interpolation") as start_mock: heatmap_widget._request_step_scan_interpolation( @@ -582,6 +584,7 @@ def test_pending_request_queueing_and_start(heatmap_widget): def test_finish_interpolation_thread_cleans_references(heatmap_widget): worker_mock = mock.Mock() thread_mock = mock.Mock() + thread_mock.isRunning.return_value = True heatmap_widget._interpolation_worker = worker_mock heatmap_widget._interpolation_thread = thread_mock From b16f88b217e8184a95270db6d2bf15d8abc49f00 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 9 Dec 2025 15:41:15 +0100 Subject: [PATCH 048/155] fix(heatmap): interpolation thread is killed only on exit, logger for dandling thread --- bec_widgets/widgets/plots/heatmap/heatmap.py | 29 +++++++++++++------- tests/unit_tests/test_heatmap_widget.py | 9 +++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index faf614538..d715772de 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -128,6 +128,12 @@ class _StepInterpolationWorker(QObject): def __init__(self, parent: QObject | None = None): super().__init__(parent=parent) self._active_request: _InterpolationRequest | None = None + self._processing = False + + @property + def is_processing(self) -> bool: + """Return whether the worker is currently processing a request.""" + return self._processing @SafeSlot(object, int) def process(self, request: _InterpolationRequest, data_version: int): @@ -139,6 +145,7 @@ def process(self, request: _InterpolationRequest, data_version: int): data_version(int): The data version for the request. """ self._active_request = request + self._processing = True try: image, transform = Heatmap.compute_step_scan_image( x_data=np.asarray(request.x_data, dtype=float), @@ -150,7 +157,9 @@ def process(self, request: _InterpolationRequest, data_version: int): except Exception as exc: # pragma: no cover - defensive logger.warning(f"Step-scan interpolation failed with: {exc}") self.failed.emit(str(exc), data_version, request.scan_id) + self._processing = False return + self._processing = False self.finished.emit(image, transform, data_version, request.scan_id) @@ -682,7 +691,7 @@ def _request_step_scan_interpolation( oversampling_factor=self._image_config.oversampling_factor, ) - if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): + if self._interpolation_worker is not None and self._interpolation_worker.is_processing: self._pending_interpolation_request = request return @@ -721,16 +730,10 @@ def _on_interpolation_finished( self._apply_image_update(img, transform) else: logger.info("Discarding outdated interpolation result.") - if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): - self._interpolation_thread.quit() - self._interpolation_thread.wait() self._maybe_start_pending_interpolation() def _on_interpolation_failed(self, error: str, data_version: int, scan_id: str): logger.warning(f"Interpolation failed for scan {scan_id} (version {data_version}): {error}") - if self._interpolation_thread is not None and self._interpolation_thread.isRunning(): - self._interpolation_thread.quit() - self._interpolation_thread.wait() self._maybe_start_pending_interpolation() def _finish_interpolation_thread(self): @@ -738,17 +741,21 @@ def _finish_interpolation_thread(self): if self._interpolation_worker is not None: try: self.interpolation_requested.disconnect(self._interpolation_worker.process) - except (TypeError, RuntimeError): - # Defensive: disconnect may fail if already disconnected or during shutdown. + except (TypeError, RuntimeError) as ext: + logger.warning(f"Processing thread already disconnected: {ext}") pass self._interpolation_worker.deleteLater() self._interpolation_worker = None if self._interpolation_thread is not None: if self._interpolation_thread.isRunning(): self._interpolation_thread.quit() - self._interpolation_thread.wait() + if not self._interpolation_thread.wait(3000): # 3s timeout + logger.error( + f"Interpolation thread of widget {self.gui_id} did not stop within timeout 3s; leaving it dangling." + ) self._interpolation_thread.deleteLater() self._interpolation_thread = None + logger.info(f"Interpolation thread finished of widget {self.gui_id}") def _maybe_start_pending_interpolation(self): if self._pending_interpolation_request is None: @@ -756,6 +763,8 @@ def _maybe_start_pending_interpolation(self): if self._pending_interpolation_request.scan_id != self.scan_id: self._pending_interpolation_request = None return + if self._interpolation_worker is not None and self._interpolation_worker.is_processing: + return pending = self._pending_interpolation_request self._pending_interpolation_request = None diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index d3a64da7c..517e9f052 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -4,8 +4,8 @@ import pytest from bec_lib import messages from bec_lib.scan_history import ScanHistory -from qtpy.QtGui import QTransform from qtpy.QtCore import QPointF +from qtpy.QtGui import QTransform from bec_widgets.widgets.plots.heatmap.heatmap import ( Heatmap, @@ -560,8 +560,9 @@ def test_pending_request_queueing_and_start(heatmap_widget): metadata={}, info={"positions": [[0, 0], [1, 1], [2, 2], [3, 3]]}, ) - heatmap_widget._interpolation_thread = mock.MagicMock() - heatmap_widget._interpolation_thread.isRunning.return_value = True + # Simulate an active worker processing a job so new requests are queued. + heatmap_widget._interpolation_worker = mock.MagicMock() + heatmap_widget._interpolation_worker.is_processing = True with mock.patch.object(heatmap_widget, "_start_step_scan_interpolation") as start_mock: heatmap_widget._request_step_scan_interpolation( @@ -573,7 +574,7 @@ def test_pending_request_queueing_and_start(heatmap_widget): assert heatmap_widget._pending_interpolation_request is not None # Now simulate worker finished and thread cleaned up - heatmap_widget._interpolation_thread = None + heatmap_widget._interpolation_worker.is_processing = False pending = heatmap_widget._pending_interpolation_request heatmap_widget._pending_interpolation_request = pending heatmap_widget._maybe_start_pending_interpolation() From 53b50e3420e19ee27f85097d428627afce14befc Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 11 Dec 2025 18:39:00 +0100 Subject: [PATCH 049/155] fix(general_app): old general app example removed --- bec_widgets/examples/general_app/__init__.py | 0 .../examples/general_app/general_app.py | 92 ------ .../examples/general_app/general_app.ui | 262 ------------------ bec_widgets/examples/general_app/web_links.py | 15 - 4 files changed, 369 deletions(-) delete mode 100644 bec_widgets/examples/general_app/__init__.py delete mode 100644 bec_widgets/examples/general_app/general_app.py delete mode 100644 bec_widgets/examples/general_app/general_app.ui delete mode 100644 bec_widgets/examples/general_app/web_links.py diff --git a/bec_widgets/examples/general_app/__init__.py b/bec_widgets/examples/general_app/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bec_widgets/examples/general_app/general_app.py b/bec_widgets/examples/general_app/general_app.py deleted file mode 100644 index 8ea531ead..000000000 --- a/bec_widgets/examples/general_app/general_app.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import sys - -from qtpy.QtCore import QSize -from qtpy.QtGui import QActionGroup, QIcon -from qtpy.QtWidgets import QApplication, QMainWindow, QStyle - -import bec_widgets -from bec_widgets.examples.general_app.web_links import BECWebLinksMixin -from bec_widgets.utils.colors import apply_theme -from bec_widgets.utils.ui_loader import UILoader - -MODULE_PATH = os.path.dirname(bec_widgets.__file__) - - -class BECGeneralApp(QMainWindow): - def __init__(self, parent=None): - super(BECGeneralApp, self).__init__(parent) - ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui") - self.load_ui(ui_file_path) - - self.resize(1280, 720) - - self.ini_ui() - - def ini_ui(self): - self._setup_icons() - self._hook_menubar_docs() - self._hook_theme_bar() - - def load_ui(self, ui_file): - loader = UILoader(self) - self.ui = loader.loader(ui_file) - self.setCentralWidget(self.ui) - - def _hook_menubar_docs(self): - # BEC Docs - self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) - # BEC Widgets Docs - self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs) - # Bug report - self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) - - def change_theme(self, theme): - apply_theme(theme) - - def _setup_icons(self): - help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) - bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) - computer_icon = QIcon.fromTheme("computer") - widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png")) - - self.ui.action_BEC_docs.setIcon(help_icon) - self.ui.action_BEC_widgets_docs.setIcon(help_icon) - self.ui.action_bug_report.setIcon(bug_icon) - - self.ui.central_tab.setTabIcon(0, widget_icon) - self.ui.central_tab.setTabIcon(1, computer_icon) - - def _hook_theme_bar(self): - self.ui.action_light.setCheckable(True) - self.ui.action_dark.setCheckable(True) - - # Create an action group to make sure only one can be checked at a time - theme_group = QActionGroup(self) - theme_group.addAction(self.ui.action_light) - theme_group.addAction(self.ui.action_dark) - theme_group.setExclusive(True) - - # Connect the actions to the theme change method - - self.ui.action_light.triggered.connect(lambda: self.change_theme("light")) - self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark")) - - self.ui.action_dark.trigger() - - -def main(): # pragma: no cover - - app = QApplication(sys.argv) - icon = QIcon() - icon.addFile( - os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48) - ) - app.setWindowIcon(icon) - main_window = BECGeneralApp() - main_window.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/bec_widgets/examples/general_app/general_app.ui b/bec_widgets/examples/general_app/general_app.ui deleted file mode 100644 index 3a70bc77f..000000000 --- a/bec_widgets/examples/general_app/general_app.ui +++ /dev/null @@ -1,262 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1718 - 1139 - - - - MainWindow - - - QTabWidget::TabShape::Rounded - - - - - - - 0 - - - - Dock Area - - - - 2 - - - 1 - - - 2 - - - 2 - - - - - - - - - - - - Visual Studio Code - - - - 2 - - - 1 - - - 2 - - - 2 - - - - - - - - - - - - - - 0 - 0 - 1718 - 31 - - - - - Help - - - - - - - - Theme - - - - - - - - - - - Scan Control - - - 2 - - - - - - - - - - - - BEC Service Status - - - 2 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - Scan Queue - - - 2 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - - - - - - - - - - - - BEC Docs - - - - - - - - BEC Widgets Docs - - - - - - - - Bug Report - - - - - true - - - Light - - - - - true - - - Dark - - - - - - WebsiteWidget - QWebEngineView -
website_widget
-
- - BECQueue - QTableWidget -
bec_queue
-
- - ScanControl - QWidget -
scan_control
-
- - VSCodeEditor - WebsiteWidget -
vs_code_editor
-
- - BECStatusBox - QWidget -
bec_status_box
-
- - BECDockArea - QWidget -
dock_area
-
- - QWebEngineView - -
QtWebEngineWidgets/QWebEngineView
-
-
- - -
diff --git a/bec_widgets/examples/general_app/web_links.py b/bec_widgets/examples/general_app/web_links.py deleted file mode 100644 index 619e6d1e5..000000000 --- a/bec_widgets/examples/general_app/web_links.py +++ /dev/null @@ -1,15 +0,0 @@ -import webbrowser - - -class BECWebLinksMixin: - @staticmethod - def open_bec_docs(): - webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/") - - @staticmethod - def open_bec_widgets_docs(): - webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/") - - @staticmethod - def open_bec_bug_report(): - webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/") From de5773662a7c16d34afa8cb604e791551206d4ac Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 23:50:28 +0100 Subject: [PATCH 050/155] feat(device-manager): Add DeviceManager Widget for BEC Widget main applications --- bec_widgets/applications/main_app.py | 6 +- .../device_manager_dialogs/__init__.py | 2 + .../config_choice_dialog.py | 49 + .../device_form_dialog.py | 341 +++ .../upload_redis_dialog.py | 720 ++++++ .../device_manager_display_widget.py | 665 +++++ .../device_manager_view.py | 711 +----- .../device_manager_widget.py | 47 +- bec_widgets/utils/bec_list.py | 93 + bec_widgets/utils/error_popups.py | 4 +- .../control/device_manager/__init__.py | 1 + .../device_manager/components/__init__.py | 7 +- .../device_manager/components/constants.py | 161 +- .../device_config_template/__init__.py | 0 .../device_config_template.py | 519 ++++ .../device_config_template/template_items.py | 481 ++++ .../components/device_table/__init__.py | 0 .../components/device_table/device_table.py | 1002 ++++++++ .../device_table/device_table_row.py | 56 + .../components/device_table_view.py | 1129 -------- .../components/dm_config_view.py | 45 +- .../components/dm_docstring_view.py | 9 +- .../components/dm_ophyd_test.py | 418 --- .../components/ophyd_validation/__init__.py | 8 + .../ophyd_validation/ophyd_validation.py | 825 ++++++ .../ophyd_validation_utils.py | 171 ++ .../ophyd_validation/validation_list_item.py | 391 +++ .../control/device_manager/device_manager.py | 4 - tests/unit_tests/conftest.py | 4 + .../test_device_manager_components.py | 2270 +++++++++++------ tests/unit_tests/test_device_manager_view.py | 743 ++++-- tests/unit_tests/test_utils_bec_list.py | 128 + 32 files changed, 7741 insertions(+), 3269 deletions(-) create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py create mode 100644 bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py create mode 100644 bec_widgets/utils/bec_list.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/device_table.py create mode 100644 bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py delete mode 100644 bec_widgets/widgets/control/device_manager/components/device_table_view.py delete mode 100644 bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py create mode 100644 bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py delete mode 100644 bec_widgets/widgets/control/device_manager/device_manager.py create mode 100644 tests/unit_tests/test_utils_bec_list.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 2eccb7006..6f4ba3547 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -4,9 +4,7 @@ from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem from bec_widgets.applications.views.developer_view.developer_view import DeveloperView -from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( - DeviceManagerWidget, -) +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea @@ -51,7 +49,7 @@ def _add_views(self): self, profile_namespace="main_workspace", auto_profile_namespace=False ) self.ads.setObjectName("MainWorkspace") - self.device_manager = DeviceManagerWidget(self) + self.device_manager = DeviceManagerView(self) self.developer_view = DeveloperView(self) self.add_view( diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py new file mode 100644 index 000000000..b507c9d9f --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/__init__.py @@ -0,0 +1,2 @@ +from .config_choice_dialog import ConfigChoiceDialog +from .device_form_dialog import DeviceFormDialog diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py new file mode 100644 index 000000000..db91597fc --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/config_choice_dialog.py @@ -0,0 +1,49 @@ +"""Dialog to choose config loading method: replace, add or cancel.""" + +from enum import IntEnum + +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout + + +class ConfigChoiceDialog(QDialog): + class Result(IntEnum): + CANCEL = QDialog.Rejected + ADD = 2 + REPLACE = 3 + + def __init__( + self, + parent=None, + custom_label: str = "Do you want to replace the current config or add to it?", + ): + super().__init__(parent) + self.setWindowTitle("Load Config") + + layout = QVBoxLayout(self) + + label = QLabel(custom_label) + label.setWordWrap(True) + layout.addWidget(label) + + # Use QDialogButtonBox for native layout + self.button_box = QDialogButtonBox(self) + self.cancel_btn = self.button_box.addButton( + "Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept... + ) + self.replace_btn = self.button_box.addButton( + "Replace", QDialogButtonBox.ButtonRole.AcceptRole + ) + self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole) + + layout.addWidget(self.button_box) + + for btn in [self.replace_btn, self.add_btn, self.cancel_btn]: + btn.setMinimumWidth(80) + btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + # Connections using native done(int) + self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE)) + self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD)) + self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL)) + + self.replace_btn.setFocus() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py new file mode 100644 index 000000000..ca31df964 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -0,0 +1,341 @@ +"""Dialogs for device configuration forms and ophyd testing.""" + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components import OphydValidation +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, +) +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + validate_name, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + format_error_to_md, +) + +DEFAULT_DEVICE = "CustomDevice" + + +logger = bec_logger.logger + + +class DeviceManagerOphydValidationDialog(QtWidgets.QDialog): + """Popup dialog to test Ophyd device configurations interactively.""" + + def __init__(self, parent=None, config: dict | None = None): # type:ignore + super().__init__(parent) + self.setWindowTitle("Device Manager Ophyd Test") + self._config_status = ConfigStatus.UNKNOWN.value + self._connection_status = ConnectionStatus.UNKNOWN.value + self._validated_config: dict = {} + self._validation_msg: str = "" + + layout = QtWidgets.QVBoxLayout(self) + + # Core test widget + self.device_manager_ophyd_test = OphydValidation() + layout.addWidget(self.device_manager_ophyd_test) + + # Log/Markdown box for messages + self.text_box = QtWidgets.QTextEdit() + self.text_box.setReadOnly(True) + layout.addWidget(self.text_box) + + # Connect signal for validation messages + + # Load and apply configuration + config = config or {} + self.device_manager_ophyd_test.change_device_configs([config], True, True) + + # Dialog Buttons: equal size, stacked horizontally + button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) + for button in button_box.buttons(): + button.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed + ) + button.clicked.connect(self.accept) + # button_box.setCenterButtons(False) + layout.addWidget(button_box) + self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated) + self._resize_dialog() + self.finished.connect(self._finished) + + def _resize_dialog(self): + """Resize the dialog based on the screen size.""" + app: QtCore.QCoreApplication = QtWidgets.QApplication.instance() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 4:3 ratio + height = int(screen_height * 0.7) + width = int(height * (4 / 3)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (4 / 3)) + + self.resize(width, height) + + def _on_device_validated( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ): + device_name = device_config.get("name", "") + self._config_status = config_status + self._connection_status = connection_status + self._validated_config = device_config + self._validation_msg = validation_msg + self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg)) + + @SafeSlot(int) + def _finished(self, state: int): + self.device_manager_ophyd_test.close() + self.device_manager_ophyd_test.deleteLater() + + @property + def validation_result(self) -> tuple[dict, int, int, str]: + """ + Return the result of the validation as a tuple of + + Returns: + result (Tuple[dict, int, int]): A tuple containing: + validated_config (dict): The validated device configuration. + config_status (int): The configuration status. + connection_status (int): The connection status. + + """ + return ( + self._validated_config, + self._config_status, + self._connection_status, + self._validation_msg, + ) + + +class DeviceFormDialog(QtWidgets.QDialog): + + # Signal emitted when device configuration is accepted, only + # emitted when the user clicks the "Add Device" button + # The integer values indicate if the device config was + # validated: config_status, connection_status + accepted_data = QtCore.Signal(dict, int, int, str, str) + + def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore + super().__init__(parent) + # Track old device name if config is edited + self._old_device_name: str = "" + + # Config validation result + self._validation_result: tuple[dict, int, int, str] = ( + {}, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "", + ) + # Group to variants mapping + self._group_variants: dict[str, list[str]] = { + group: [variant for variant in variants.keys()] + for group, variants in OPHYD_DEVICE_TEMPLATES.items() + } + + self._control_widgets: dict[str, QtWidgets.QWidget] = {} + + # Setup layout + self.setWindowTitle("Device Config Dialog") + layout = QtWidgets.QVBoxLayout(self) + + # Control panel + self._control_box = self.create_control_panel() + layout.addWidget(self._control_box) + + # Device config template display + self._device_config_template = DeviceConfigTemplate(parent=self) + self._frame = QtWidgets.QFrame() + self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self._frame.setFrameShadow(QtWidgets.QFrame.Raised) + frame_layout = QtWidgets.QVBoxLayout(self._frame) + frame_layout.addWidget(self._device_config_template) + layout.addWidget(self._frame) + + # Custom buttons + self.add_btn = QtWidgets.QPushButton(add_btn_text) + self.test_connection_btn = QtWidgets.QPushButton("Test Connection") + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.reset_btn = QtWidgets.QPushButton("Reset Form") + + btn_layout = QtWidgets.QHBoxLayout() + for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn): + btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + btn_layout.addWidget(btn) + btn_box = QtWidgets.QGroupBox("Actions") + btn_box.setLayout(btn_layout) + frame_layout.addWidget(btn_box) + + # Connect signals to explicit slots + self.add_btn.clicked.connect(self._add_config) + self.test_connection_btn.clicked.connect(self._test_connection) + self.reset_btn.clicked.connect(self._reset_config) + self.cancel_btn.clicked.connect(self._reject_config) + + # layout.addWidget(self._device_config_template) + self.update_variant_combo(self._control_widgets["group_combo"].currentText()) + self.finished.connect(self._finished) + + @SafeSlot(int) + def _finished(self, state: int): + for widget in self._control_widgets.values(): + widget.close() + widget.deleteLater() + + @property + def config_validation_result(self) -> tuple[dict, int, int, str]: + """Return the result of the last configuration validation.""" + return self._validation_result + + @config_validation_result.setter + def config_validation_result(self, result: tuple[dict, int, int, str]): + self._validation_result = result + + def set_device_config(self, device_config: dict): + """Set the device configuration in the template form.""" + # Figure out which group and variant this config belongs to + device_class = device_config.get("deviceClass", None) + for group, variants in OPHYD_DEVICE_TEMPLATES.items(): + for variant, template_info in variants.items(): + if template_info.get("deviceClass", None) == device_class: + # Found the matching group and variant + self._control_widgets["group_combo"].setCurrentText(group) + self.update_variant_combo(group) + self._control_widgets["variant_combo"].setCurrentText(variant) + self._device_config_template.set_config_fields(device_config) + return + # If no match found, set to default + self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE) + self.update_variant_combo(DEFAULT_DEVICE) + self._device_config_template.set_config_fields(device_config) + self._old_device_name = device_config.get("name", "") + + def sizeHint(self) -> QtCore.QSize: + return QtCore.QSize(1600, 1000) + + def create_control_panel(self) -> QtWidgets.QGroupBox: + self._control_box = QtWidgets.QGroupBox("Choose a Device Group") + layout = QtWidgets.QGridLayout(self._control_box) + + group_label = QtWidgets.QLabel("Device Group:") + layout.addWidget(group_label, 0, 0) + + group_combo = QtWidgets.QComboBox() + group_combo.addItems(self._group_variants.keys()) + self._control_widgets["group_combo"] = group_combo + layout.addWidget(group_combo, 1, 0) + + variant_label = QtWidgets.QLabel("Variants:") + layout.addWidget(variant_label, 0, 1) + + variant_combo = QtWidgets.QComboBox() + self._control_widgets["variant_combo"] = variant_combo + layout.addWidget(variant_combo, 1, 1) + + group_combo.currentTextChanged.connect(self.update_variant_combo) + variant_combo.currentTextChanged.connect(self.update_device_config_template) + + return self._control_box + + def update_variant_combo(self, group_name: str): + variant_combo = self._control_widgets["variant_combo"] + variant_combo.clear() + variant_combo.addItems(self._group_variants.get(group_name, [])) + if variant_combo.count() <= 1: + variant_combo.setEnabled(False) + else: + variant_combo.setEnabled(True) + + def update_device_config_template(self, variant_name: str): + group_name = self._control_widgets["group_combo"].currentText() + template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {}) + if template_info: + self._device_config_template.change_template(template_info) + else: + self._device_config_template.change_template( + OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE] + ) + + def _add_config(self): + config = self._device_config_template.get_config_fields() + config_status = ConfigStatus.UNKNOWN.value + connection_status = ConnectionStatus.UNKNOWN.value + validation_msg = "" + try: + if DeviceModel.model_validate(config) == DeviceModel.model_validate( + self._validation_result[0] + ): + config_status = self._validation_result[1] + connection_status = self._validation_result[2] + validation_msg = self._validation_result[3] + except Exception: + logger.debug( + f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses." + ) + + if not validate_name(config.get("name", "")): + msg_box = self._create_warning_message_box( + "Invalid Device Name", + f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ", + ) + msg_box.exec() + return + if config_status == ConfigStatus.INVALID.value: + msg_box = self._create_warning_message_box( + "Invalid Device Configuration", + f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}", + ) + msg_box.exec() + return + + self.accepted_data.emit( + config, config_status, connection_status, validation_msg, self._old_device_name + ) + self.accept() + + def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox: + msg_box = QtWidgets.QMessageBox(self) + msg_box.setIcon(QtWidgets.QMessageBox.Warning) + msg_box.setWindowTitle(title) + msg_box.setText(text) + return msg_box + + def _test_connection(self): + config = self._device_config_template.get_config_fields() + dialog = DeviceManagerOphydValidationDialog(self, config=config) + result = dialog.exec() + if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected): + self.config_validation_result = dialog.validation_result + # self._device_config_template.set_config_fields(self.config_validation_result[0]) + + def _reset_config(self): + self._device_config_template.reset_to_defaults() + + def _reject_config(self): + self.reject() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("light") + + dialog = DeviceFormDialog() + dialog.resize(1200, 800) + dialog.show() + sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py new file mode 100644 index 000000000..cb6f52b3a --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py @@ -0,0 +1,720 @@ +"""Module for the upload redis dialog in the device manager view.""" + +from __future__ import annotations + +from enum import IntEnum +from functools import partial +from typing import TYPE_CHECKING, Dict, List, Tuple + +from bec_lib.logger import bec_logger +from bec_qthemes import apply_theme, material_icon +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components import OphydValidation +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) +from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar + +if TYPE_CHECKING: + from bec_widgets.utils.colors import AccentColor + +logger = bec_logger.logger + + +class DeviceStatusItem(QtWidgets.QWidget): + """Individual device status item widget for the validation display.""" + + def __init__( + self, device_config: dict, config_status: int, connection_status: int, parent=None + ): + super().__init__(parent) + self.device_name = device_config.get("name", "") + self.device_config: dict = device_config + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._transparent_button_style = "background-color: transparent; border: none;" + + # Get validation icons + self.colors = get_accent_colors() + self._icon_size = (20, 20) + self.icons = get_validation_icons(self.colors, self._icon_size) + + self._setup_ui() + self._update_display() + + def _setup_ui(self): + """Setup the UI for the device status item.""" + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + + # Device name label + self.name_label = QtWidgets.QLabel(self.device_name) + self.name_label.setMinimumWidth(150) + layout.addWidget(self.name_label) + layout.addStretch() + + # Config status icon + self.config_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.config_icon_label) + + # Connection status icon + self.connection_icon_label = self._create_status_icon_label(self._icon_size) + layout.addWidget(self.connection_icon_label) + + def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton: + button = QtWidgets.QPushButton() + button.setFlat(True) + button.setEnabled(False) + button.setStyleSheet(self._transparent_button_style) + button.setFixedSize(icon_size[0], icon_size[1]) + return button + + def _update_display(self): + """Update the visual display based on current status.""" + # Update config status + config_icon = self.icons["config_status"].get(self.config_status.value) + if config_icon: + self.config_icon_label.setIcon(config_icon) + + # Update connection status + connection_icon = self.icons["connection_status"].get(self.connection_status.value) + if connection_icon: + self.connection_icon_label.setIcon(connection_icon) + + def update_status(self, config_status: int, connection_status: int): + """Update the status and refresh display.""" + self.config_status = ConfigStatus(config_status) + self.connection_status = ConnectionStatus(connection_status) + self._update_display() + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status < other_data.config_status + else: + return self_data.connection_status < other_data.connection_status + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + self_data: DeviceStatusItem + other_data: DeviceStatusItem + if self_data.config_status != other_data.config_status: + return self_data.config_status > other_data.config_status + else: + return self_data.connection_status > other_data.connection_status + return super().__gt__(other) + + +class ValidationSection(QtWidgets.QGroupBox): + """Section widget for displaying validation results.""" + + def __init__(self, title: str, parent=None): + super().__init__(title, parent=parent) + self._setup_ui() + # self.device_items: Dict[str, DeviceStatusItem] = {} + + def _setup_ui(self): + """Setup the UI for the validation section.""" + layout = QtWidgets.QVBoxLayout(self) + + # Status summary label + summary_layout = QtWidgets.QHBoxLayout() + self.summary_icon = QtWidgets.QLabel() + self.summary_icon.setFixedSize(24, 24) + self.summary_label = QtWidgets.QLabel() + self.summary_label.setWordWrap(True) + summary_layout.addWidget(self.summary_icon) + summary_layout.addWidget(self.summary_label) + layout.addLayout(summary_layout) + + # Scroll area for device items + self.table = QtWidgets.QTableWidget() + self.table.setColumnCount(1) + self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.table.horizontalHeader().hide() + self.table.verticalHeader().hide() + self.table.setShowGrid(False) # r + self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder) + layout.addWidget(self.table) + QtCore.QTimer.singleShot(0, self.adjustSize) + + def add_device(self, device_config: dict, config_status: int, connection_status: int): + """ + Add a device to the validation section. + + Args: + device_config (dict): The device configuration dictionary. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.table.setSortingEnabled(False) + device_name = device_config.get("name", "") + row = self._find_row_by_name(device_name) + if row is not None: + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + widget.update_status(config_status, connection_status) + else: + row_position = self.table.rowCount() + self.table.insertRow(row_position) + sort_item = SortTableItem(device_name) + sort_item.setText("") + self.table.setItem(row_position, 0, sort_item) + device_item = DeviceStatusItem(device_config, config_status, connection_status) + sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item) + self.table.setCellWidget(row_position, 0, device_item) + self.table.resizeRowsToContents() + self.table.setSortingEnabled(True) + + def _find_row_by_name(self, device_name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + item: SortTableItem = self.table.item(row, 0) + widget: DeviceStatusItem = self.table.cellWidget(row, 0) + if widget.device_name == device_name: + return row + return None + + def remove_device(self, device_name: str): + """Remove a device from the table by name.""" + self.table.setSortingEnabled(False) + row = self._find_row_by_name(device_name) + if row is not None: + self.table.removeRow(row) + self.table.setSortingEnabled(True) + + def clear_devices(self): + """Clear all device items.""" + self.table.setSortingEnabled(False) + while self.table.rowCount() > 0: + self.table.removeRow(0) + self.table.setSortingEnabled(True) + + def update_summary(self, text: str, icon: QtGui.QPixmap = None): + """Update the summary label.""" + self.summary_label.setText(text) + if icon: + self.summary_icon.setPixmap(icon) + + +class UploadRedisDialog(QtWidgets.QDialog): + """ + Dialog for uploading device configurations to BEC server with validation checks. + """ + + class UploadAction(IntEnum): + """Enum for upload actions.""" + + CANCEL = QtWidgets.QDialog.Rejected + OK = QtWidgets.QDialog.Accepted + + # Signal to trigger upload after confirmation + upload_confirmed = QtCore.Signal(int) + + def __init__( + self, + parent, + ophyd_test_widget: OphydValidation, + device_configs: dict[str, Tuple[dict, int, int]] | None = None, + ): + super().__init__(parent=parent) + + self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {} + self.ophyd_test_widget = ophyd_test_widget + self._transparent_button_style = "background-color: transparent; border: none;" + + self.colors = get_accent_colors() + self.icons = get_validation_icons(self.colors, (20, 20)) + material_icon_partial = partial(material_icon, size=(24, 24), filled=True) + self._label_icons = { + "success": material_icon_partial("check_circle", color=self.colors.success), + "warning": material_icon_partial("warning", color=self.colors.warning), + "error": material_icon_partial("error", color=self.colors.emergency), + "reload": material_icon_partial("refresh", color=self.colors.default), + "upload": material_icon_partial("cloud_upload", color=self.colors.default), + } + + # Track validation states + self.has_invalid_configs: int = 0 + self.has_untested_connections: int = 0 + self.has_cannot_connect: int = 0 + self._current_progress: int | None = None + + self._setup_ui() + self._update_ui() + # Disable validation features if no ophyd test widget provided, else connect validation + self._validation_connection = self.ophyd_test_widget.validation_completed.connect( + self._update_from_ophyd_device_tests + ) + + def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]): + """ + Update the device configuration in the dialog. + + Args: + device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure + {device_name: (config_dict, config_status, connection_status)}. + """ + self.config_section.clear_devices() + self.device_configs = device_configs + self._update_ui() + + def accept(self): + self.cleanup() + return super().accept() + + def reject(self): + self.cleanup() + return super().reject() + + def cleanup(self): + """Cleanup on dialog finish.""" + self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection) + + def _setup_ui(self): + """Setup the main UI for the dialog.""" + self.setWindowTitle("Upload Configuration to BEC Server") + self.setModal(True) # Blocks interaction with other parts of the app + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(16) + + # Header + header_label = QtWidgets.QLabel("Review Configuration Before Upload") + header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;") + layout.addWidget(header_label) + + # Description + desc_label = QtWidgets.QLabel( + "Please review the configuration and connection status of all devices before uploading to BEC Server." + ) + desc_label.setWordWrap(True) + desc_label.setStyleSheet("color: #666; margin-bottom: 16px;") + layout.addWidget(desc_label) + + # Config validation section + sections_layout = QtWidgets.QHBoxLayout() + self.config_section = ValidationSection("Configuration Validation") + sections_layout.addWidget(self.config_section) + layout.addLayout(sections_layout) + + # Action buttons section + self._setup_action_buttons(layout) + + # Dialog buttons + self._setup_dialog_buttons(layout) + self.adjustSize() + + def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the action buttons section.""" + action_group = QtWidgets.QGroupBox("Actions") + action_layout = QtWidgets.QVBoxLayout(action_group) + + # Validate connections button + button_layout = QtWidgets.QHBoxLayout() + self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections") + self.validate_connections_btn.setIcon(self._label_icons["reload"]) + self.validate_connections_btn.clicked.connect(self._validate_connections) + button_layout.addWidget(self.validate_connections_btn) + button_layout.addStretch() + button_layout.addSpacing(16) + + # Progress bar + self._progress_bar = BECProgressBar(self) + self._progress_bar.setVisible(False) + button_layout.addWidget(self._progress_bar) + action_layout.addLayout(button_layout) + + # Status indicator + status_layout = QtWidgets.QHBoxLayout() + self.status_icon = QtWidgets.QPushButton() + self.status_icon.setFlat(True) + self.status_icon.setEnabled(False) + self.status_icon.setStyleSheet(self._transparent_button_style) + self.status_icon.setFixedSize(24, 24) + self.status_label = QtWidgets.QLabel() + self.status_label.setWordWrap(True) + status_layout.addWidget(self.status_icon) + status_layout.addWidget(self.status_label) + action_layout.addLayout(status_layout) + + parent_layout.addWidget(action_group) + + def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout): + """Setup the dialog buttons.""" + button_layout = QtWidgets.QHBoxLayout() + + # Cancel button + self.cancel_btn = QtWidgets.QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(self.cancel_btn) + + button_layout.addStretch() + + # Upload button + self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server") + self.upload_btn.setIcon(self._label_icons["upload"]) + self.upload_btn.clicked.connect(self._handle_upload) + button_layout.addWidget(self.upload_btn) + + parent_layout.addLayout(button_layout) + + def _populate_device_data(self): + """Populate the dialog with device configuration data.""" + if not self.device_configs: + return + + self.has_invalid_configs = 0 + self.has_untested_connections = 0 + self.has_cannot_connect = 0 + + for device_name, (config, config_status, connection_status) in self.device_configs.items(): + # Add to appropriate sections + self.config_section.add_device(config, config_status, connection_status) + + # Track statistics + if config_status == ConfigStatus.INVALID.value: + self.has_invalid_configs += 1 + if connection_status == ConnectionStatus.UNKNOWN.value: + self.has_untested_connections += 1 + if connection_status == ConnectionStatus.CANNOT_CONNECT.value: + self.has_cannot_connect += 1 + + # Update section summaries + num_devices = len(self.device_configs) + + # Config validation summary + if self.has_invalid_configs > 0: + icon = self._label_icons["error"] + text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid." + else: + icon = self._label_icons["success"] + text = f"All {num_devices} device configurations are valid." + if self.has_untested_connections > 0: + icon = self._label_icons["warning"] + text += f"{self.has_untested_connections} device connections are not tested." + if self.has_cannot_connect > 0: + icon = self._label_icons["warning"] + text += f"{self.has_cannot_connect} device connections cannot be established." + self.config_section.update_summary(text, icon) + + def _update_ui(self): + """Update UI state based on validation results.""" + # Update first the device data + self._populate_device_data() + + # Invalid configuration have highest priority, upload disabled + if self.has_invalid_configs: + self.status_icon.setIcon(self._label_icons["error"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_invalid_configs} device configurations are invalid.", + "Please fix configuration errors before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(False) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("Invalid Configurations") + + # Next priority: connections that cannot be established, error but upload is enabled + elif self.has_cannot_connect: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_cannot_connect} connections cannot be established.", + "Please fix connection issues before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # Next priority: untested connections, warning but upload is enabled + elif self.has_untested_connections: + self.status_icon.setIcon(self._label_icons["warning"]) + self.status_label.setText( + "\n".join( + [ + f"{self.has_untested_connections} connections have not been tested.", + "Consider validating connections before uploading.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(True) + self.validate_connections_btn.setText( + f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections" + ) + + # All good, upload enabled + else: + self.status_icon.setIcon(self._label_icons["success"]) + self.status_label.setText( + "\n".join( + [ + "All device configurations are valid.", + "All connections have been successfully tested.", + ] + ) + ) + self.upload_btn.setEnabled(True) + self.validate_connections_btn.setEnabled(False) + self.validate_connections_btn.setText("All Connections Validated") + + @SafeSlot() + def _validate_connections(self): + """Request validation of all untested connections.""" + testable_devices: List[dict] = [] + for _, (config, _, connection_status) in self.device_configs.items(): + if connection_status == ConnectionStatus.UNKNOWN.value: + testable_devices.append(config) + elif connection_status == ConnectionStatus.CANNOT_CONNECT.value: + testable_devices.append(config) + + if len(testable_devices) > 0: + self.validate_connections_btn.setEnabled(False) + self._progress_bar.setVisible(True) + self._progress_bar.maximum = len(testable_devices) + self._progress_bar.minimum = 0 + self._progress_bar.set_value(0) + self._current_progress = 0 + self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True) + + @SafeSlot() + def _handle_upload(self): + """Handle the upload button click with appropriate confirmations.""" + # First priority: invalid configurations, block upload + if self.has_invalid_configs: + detailed_text = ( + f"There is {self.has_invalid_configs} device with an invalid configuration." + if self.has_invalid_configs == 1 + else f"There are {self.has_invalid_configs} devices with invalid configurations." + ) + text = " ".join( + [detailed_text, "Invalid configuration can not be uploaded to the BEC Server."] + ) + QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text) + self.done(self.UploadAction.CANCEL) + return + + # Next priority: connections that cannot be established, show warning, but allow to proceed + if self.has_cannot_connect: + detailed_text = ( + f"There is {self.has_cannot_connect} device that cannot connect" + if self.has_cannot_connect == 1 + else f"There are {self.has_cannot_connect} devices that cannot connect." + ) + text = " ".join( + [ + detailed_text, + "These devices may not be reachable and disabled BEC upon loading the config.", + "Consider validating these connections before.", + ] + ) + reply = QtWidgets.QMessageBox.critical( + self, + "Devices cannot Connect", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # If some connections are untested, warn the user + if self.has_untested_connections: + detailed_text = ( + f"There is {self.has_untested_connections} device with untested connections." + if self.has_untested_connections == 1 + else f"There are {self.has_untested_connections} devices with untested connections." + ) + text = " ".join( + [ + detailed_text, + "Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.", + ] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Untested Connections", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # Final confirmation + text = " ".join( + ["You are about to upload the device configurations to BEC Server.", "Please confirm."] + ) + reply = QtWidgets.QMessageBox.question( + self, + "Upload to BEC Server", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.Yes, + ) + if reply == QtWidgets.QMessageBox.Yes: + self.done(self.UploadAction.OK) + else: + self.done(self.UploadAction.CANCEL) + + @SafeSlot(dict, int, int, str) + def _update_from_ophyd_device_tests( + self, + device_config: dict, + config_status: int, + connection_status: int, + validation_message: str = "", + ): + """ + Update device status from ophyd device tests. This has to be with a connection_status that was updated. + + """ + if connection_status == ConnectionStatus.UNKNOWN.value: + return + self.update_device_status(device_config, config_status, connection_status) + + @SafeSlot(dict, int, int) + def update_device_status(self, device_config: dict, config_status: int, connection_status: int): + """Update the status of a specific device.""" + # Update device config status + device_name = device_config.get("name", "") + old_config, _, _ = self.device_configs.get(device_name, (None, None, None)) + if old_config is not None: + self.device_configs[device_name] = (device_config, config_status, connection_status) + if self._current_progress is not None: + self._current_progress += 1 + self._progress_bar.set_value(self._current_progress) + if self._current_progress >= self._progress_bar.maximum: + self._progress_bar.setVisible(False) + self._progress_bar.set_value(0) + self._current_progress = None + self.validation_completed() + self._update_ui() + return + + # Update UI sections + self.config_section.add_device(device_config, config_status, connection_status) + + # Recalculate summaries and UI state + self._update_ui() + + def validation_completed(self): + """Called when connection validation is completed.""" + self.validate_connections_btn.setEnabled(True) + self._update_ui() + + +def main(): # pragma: no cover + """Test the upload redis dialog.""" + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # Sample device configurations for testing + sample_configs = [ + ( + {"name": "motor_x", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_1", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_2", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_z", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "motor_x1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ( + {"name": "detector_11", "deviceClass": "EpicsSignal"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "detector_21", "deviceClass": "EpicsSignal"}, + ConfigStatus.INVALID.value, + ConnectionStatus.UNKNOWN.value, + ), + ( + {"name": "motor_y1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + ( + {"name": "motor_z1", "deviceClass": "EpicsMotor"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + ), + ] + configs = {cfg[0]["name"]: cfg for cfg in sample_configs} + apply_theme("dark") + from unittest import mock + + ophyd_test_widget = mock.MagicMock(spec=OphydValidation) + dialog = UploadRedisDialog( + parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget + ) + dialog.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py new file mode 100644 index 000000000..d33abd292 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import List, Literal, get_args + +import yaml +from bec_lib import config_helper +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.file_utils import DeviceConfigWriter +from bec_lib.logger import bec_logger +from bec_lib.messages import ConfigAction +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_qthemes import apply_theme +from qtpy.QtCore import QMetaObject, QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget + +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + ConfigChoiceDialog, + DeviceFormDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + UploadRedisDialog, +) +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTable, + DMConfigView, + DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConnectionStatus, +) +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, +) + +logger = bec_logger.logger + +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + + +class DeviceManagerDisplayWidget(DockAreaWidget): + """Device Manager main display widget. This contains all sub-widgets and the toolbar.""" + + RPC = False + + request_ophyd_validation = Signal(list, bool, bool) + + def __init__(self, parent=None, client=None, *args, **kwargs): + super().__init__(parent=parent, variant="compact", *args, **kwargs) + + # Push to Redis dialog + self._upload_redis_dialog: UploadRedisDialog | None = None + self._dialog_validation_connection: QMetaObject.Connection | None = None + + self._config_helper = config_helper.ConfigHelper(self.client.connector) + self._shared_selection = SharedSelectionSignal() + + # Device Table View widget + self.device_table_view = DeviceTable(self) + + # Device Config View widget + self.dm_config_view = DMConfigView(self) + + # Docstring View + self.dm_docs_view = DocstringView(self) + + # Ophyd Test view + self.ophyd_widget_view = QWidget(self) + layout = QVBoxLayout(self.ophyd_widget_view) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + self.ophyd_test_view = OphydValidation(self, hide_legend=False) + layout.addWidget(self.ophyd_test_view) + + # Validation Results view + self.validation_results = QTextEdit(self) + self.validation_results.setReadOnly(True) + self.validation_results.setPlaceholderText("Validation results will appear here...") + layout.addWidget(self.validation_results) + self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb) + + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.validation_completed, + (self.device_table_view.update_device_validation,), + ), + ( + self.ophyd_test_view.multiple_validations_completed, + (self.device_table_view.update_multiple_device_validations,), + ), + (self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.change_device_configs,), + ), + ( + self.device_table_view.device_config_in_sync_with_redis, + (self._update_config_enabled_button,), + ), + (self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)), + ]: + for slot in slots: + signal.connect(slot) + + # Add toolbar + self._add_toolbar() + + # Build dock layout using shared helpers + self._build_docks() + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _build_docks(self) -> None: + # Central device table + self.device_table_view_dock = self.new( + self.device_table_view, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Bottom area: docstrings + self.dm_docs_view_dock = self.new( + self.dm_docs_view, + where="bottom", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + # Config view left of docstrings + self.dm_config_view_dock = self.new( + self.dm_config_view, + where="left", + relative_to=self.dm_docs_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + # Right area: ophyd test + validation + self.ophyd_test_dock_view = self.new( + self.ophyd_widget_view, + where="right", + relative_to=self.device_table_view_dock, + return_dock=True, + closable=False, + floatable=False, + movable=False, + show_title_bar=False, + ) + + self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]}) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Load from disk + load = MaterialIconAction( + text_position="under", + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + save_to_disk = MaterialIconAction( + text_position="under", + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", + ) + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") + + # Add flush config in redis + flush_redis = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Flush current config in BEC Server", + label_text="Flush loaded Config", + ) + flush_redis.action.triggered.connect(self._flush_redis_action) + self.toolbar.components.add_safe("flush_redis", flush_redis) + io_bundle.add_action("flush_redis") + + # Add load config from redis + load_redis = MaterialIconAction( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from BEC Server", + label_text="Get loaded Config", + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + text_position="under", + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in BEC Server", + label_text="Update Config", + ) + update_config_redis.action.setEnabled(False) + + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + # Table actions + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config View", + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction( + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", + ) + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", + ) + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._run_validate_connection) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + @SafeSlot() + @SafeSlot(bool) + def _run_validate_connection(self, connect: bool = True): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + configs = list(self.device_table_view.get_selected_device_configs()) + if not configs: + configs = self.device_table_view.get_device_config() + self.request_ophyd_validation.emit(configs, True, connect) + + def _update_config_enabled_button(self, enabled: bool): + action = self.toolbar.components.get_action("update_config_redis") + action.action.setEnabled(not enabled) + if enabled: + action.action.setToolTip("Push current config to BEC Server") + else: + action.action.setToolTip("Current config is in sync with BEC Server, button disabled.") + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + config_path = self._get_config_base_path() + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_config_base_path(self) -> str: + """Get the base path for device configurations.""" + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + return config_path + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + ALLOWED_EXTS = [".yaml", ".yml"] + filter_str = "YAML files (*.yaml *.yml);;All Files (*)" + initial_filter = "YAML files (*.yaml *.yml);;" + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, + caption="Select Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, + caption="Save Config File", + dir=start_dir, + filter=filter_str, + selectedFilter=initial_filter, + ) + if not file_path: + return "" + _, ext = os.path.splitext(file_path) + if ext.lower() not in ALLOWED_EXTS: + file_path += ".yaml" + return file_path + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ + if len(self.device_table_view.get_device_config()) == 0: + # If no config is composed yet, load directly + self.device_table_view.set_device_config(config) + return + dialog = ConfigChoiceDialog(self) + result = dialog.exec() + if result == ConfigChoiceDialog.Result.REPLACE: + self.device_table_view.set_device_config(config) + elif result == ConfigChoiceDialog.Result.ADD: + self.device_table_view.add_device_configs(config) + + @SafeSlot() + def _flush_redis_action(self): + """Action to flush the current config in Redis.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if len(self.client.device_manager.devices) == 0: + logger.info("No devices in BEC Server, nothing to flush.") + QMessageBox.information( + self, "No Devices", "There is currently no config loaded on the BEC Server." + ) + return + reply = _yes_no_question( + self, + "Flush BEC Server Config", + "Do you really want to flush the current config in BEC Server?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.set_busy(enabled=True, text="Flushing configuration in BEC Server...") + self.client.config.reset_config() + logger.info("Successfully flushed configuration in BEC Server.") + self.set_busy(enabled=False) + # Check if config is in sync, enable load redis button + self.device_table_view.device_config_in_sync_with_redis.emit( + self.device_table_view._is_config_in_sync_with_redis() + ) + validation_results = self.device_table_view.get_validation_results() + for config, config_status, connnection_status in validation_results.values(): + if connnection_status == ConnectionStatus.CONNECTED.value: + self.device_table_view.update_device_validation( + config, config_status, ConnectionStatus.CAN_CONNECT, "" + ) + + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + if self.client.device_manager is None: + logger.error("No device manager connected, cannot load config from BEC Server.") + return + if not self.device_table_view.get_device_config(): + # If no config is composed yet, load directly + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + return + reply = _yes_no_question( + self, + "Load currently active config in BEC Server", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """Action to push the current composition to Redis using the upload dialog.""" + # Check if validations are still running + if self.ophyd_test_view.running_ophyd_tests is True: + return QMessageBox.warning( + self, "Validation in Progress", "Please wait for the validation to finish." + ) + + # Get all device configurations with their validation status + validation_results = self.device_table_view.get_validation_results() + # Create and show upload dialog + self._upload_redis_dialog = UploadRedisDialog( + parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view + ) + + # Show dialog + reply = self._upload_redis_dialog.exec_() + + if reply == UploadRedisDialog.UploadAction.OK: + self._push_composition_to_redis(action="set") + elif reply == UploadRedisDialog.UploadAction.CANCEL: + self.ophyd_test_view.cancel_all_validations() + + def _push_composition_to_redis(self, action: ConfigAction): + """Push the current device composition to Redis.""" + if action not in get_args(ConfigAction): + logger.error(f"Invalid config action: {action} for uploading to BEC Server.") + return + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, action) + comm.signals.done.connect(self._handle_push_complete_to_communicator) + comm.signals.error.connect(self._handle_exception_from_communicator) + threadpool.start(comm) + self.set_busy(enabled=True, text="Uploading configuration to BEC Server...") + + def _handle_push_complete_to_communicator(self): + """Handle completion of the config push to Redis.""" + self.set_busy(enabled=False) + self._update_validation_icons_after_upload() + + def _handle_exception_from_communicator(self, exception: Exception): + """Handle exceptions from the config communicator.""" + QMessageBox.critical( + self, + "Error Uploading Config", + f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", + ) + self.set_busy(enabled=False) + self._update_validation_icons_after_upload() + + def _update_validation_icons_after_upload(self): + """Update validation icons after uploading config to Redis.""" + if self.client.device_manager is None: + return + device_names_in_session = list(self.client.device_manager.devices.keys()) + validation_results = self.device_table_view.get_validation_results() + devices_to_update = [] + for config, config_status, connection_status in validation_results.values(): + if config["name"] in device_names_in_session: + devices_to_update.append( + (config, config_status, ConnectionStatus.CONNECTED.value, "") + ) + self.device_table_view.update_multiple_device_validations(devices_to_update) + + @SafeSlot() + def _save_to_disk_action(self): + """Action for the 'save_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path = self._get_file_path(config_path, "save_file") + if file_path: + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} + if os.path.exists(file_path): + reply = _yes_no_question( + self, + "Overwrite File", + f"The file '{file_path}' already exists. Do you want to overwrite it?", + ) + if reply != QMessageBox.StandardButton.Yes: + return + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # Table actions + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + @SafeSlot(dict) + def _edit_device_action(self, device_config: dict): + """Action to edit a selected device configuration.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes") + dialog.accepted_data.connect(self._update_device_to_table_from_dialog) + dialog.set_device_config(device_config) + dialog.open() + + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device") + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict, int, int, str, str) + def _update_device_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + if old_device_name and old_device_name != data.get("name", ""): + self.device_table_view.remove_device(old_device_name) + self.device_table_view.update_device_configs([data]) + + @SafeSlot(dict, int, int, str, str) + def _add_to_table_from_dialog( + self, + data: dict, + config_status: int, + connection_status: int, + msg: str, + old_device_name: str = "", + ): + self.device_table_view.add_device_configs([data]) + + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + configs = self.device_table_view.get_selected_device_configs() + if not configs: + QMessageBox.warning( + self, "No devices selected", "Please select devices from the table to remove." + ) + return + if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.device_table_view.remove_device_configs(configs) + + @SafeSlot(dict, int, int, str, str) + def _ophyd_test_item_clicked_cb( + self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str + ) -> None: + self.validation_results.setMarkdown(md_msg) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerDisplayWidget() + l.addWidget(device_manager_view) + w.show() + w.setWindowTitle("Device Manager View") + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + w.resize(width, height) + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 3029adae3..24e80688b 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -1,686 +1,73 @@ -from __future__ import annotations +"""Module for Device Manager View.""" -import os -from functools import partial -from typing import List, Literal +from qtpy.QtWidgets import QWidget -import yaml -from bec_lib import config_helper -from bec_lib.bec_yaml_loader import yaml_load -from bec_lib.file_utils import DeviceConfigWriter -from bec_lib.logger import bec_logger -from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path -from bec_qthemes import apply_theme -from qtpy.QtCore import Qt, QThreadPool, QTimer -from qtpy.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QSizePolicy, - QSplitter, - QTextEdit, - QVBoxLayout, - QWidget, +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, ) - -import bec_widgets.widgets.containers.qt_ads as QtAds -from bec_widgets import BECWidget +from bec_widgets.applications.views.view import ViewBase from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.help_inspector.help_inspector import HelpInspector -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.control.device_manager.components import ( - DeviceTableView, - DMConfigView, - DMOphydTest, - DocstringView, -) -from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal -from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( - AvailableDeviceResources, -) -from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( - CommunicateConfigAction, -) -from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - PresetClassDeviceConfigDialog, -) - -logger = bec_logger.logger -_yes_no_question = partial( - QMessageBox.question, - buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - defaultButton=QMessageBox.StandardButton.No, -) - -def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: +class DeviceManagerView(ViewBase): """ - Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. - Works for horizontal or vertical splitters and sets matching stretch factors. + A view for users to manage devices within the application. """ - def apply(): - n = splitter.count() - if n == 0: - return - w = list(weights[:n]) + [1] * max(0, n - len(weights)) - w = [max(0.0, float(x)) for x in w] - tot_w = sum(w) - if tot_w <= 0: - w = [1.0] * n - tot_w = float(n) - total_px = ( - splitter.width() - if splitter.orientation() == Qt.Orientation.Horizontal - else splitter.height() - ) - if total_px < 2: - QTimer.singleShot(0, apply) - return - sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] - diff = total_px - sum(sizes) - if diff != 0: - idx = max(range(n), key=lambda i: w[i]) - sizes[idx] = max(1, sizes[idx] + diff) - splitter.setSizes(sizes) - for i, wi in enumerate(w): - splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) - - QTimer.singleShot(0, apply) - - -class ConfigChoiceDialog(QDialog): - REPLACE = 1 - ADD = 2 - CANCEL = 0 - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Load Config") - layout = QVBoxLayout(self) - - label = QLabel("Do you want to replace the current config or add to it?") - label.setWordWrap(True) - layout.addWidget(label) - - # Buttons: equal size, stacked vertically - self.replace_btn = QPushButton("Replace") - self.add_btn = QPushButton("Add") - self.cancel_btn = QPushButton("Cancel") - btn_layout = QHBoxLayout() - for btn in (self.replace_btn, self.add_btn, self.cancel_btn): - btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - btn_layout.addWidget(btn) - layout.addLayout(btn_layout) - - # Connect signals to explicit slots - self.replace_btn.clicked.connect(self.accept_replace) - self.add_btn.clicked.connect(self.accept_add) - self.cancel_btn.clicked.connect(self.reject_cancel) - - self._result = self.CANCEL - - def accept_replace(self): - self._result = self.REPLACE - self.accept() - - def accept_add(self): - self._result = self.ADD - self.accept() - - def reject_cancel(self): - self._result = self.CANCEL - self.reject() - - def result(self): - return self._result - - -AVAILABLE_RESOURCE_IS_READY = False - - -class DeviceManagerView(BECWidget, QWidget): - - def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent=parent, client=None, *args, **kwargs) - - self._config_helper = config_helper.ConfigHelper(self.client.connector) - self._shared_selection = SharedSelectionSignal() - - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - self.dock_manager = QtAds.CDockManager(self) - self.dock_manager.setStyleSheet("") - self._root_layout.addWidget(self.dock_manager) - - # Device Table View widget - self.device_table_view = DeviceTableView( - self, shared_selection_signal=self._shared_selection - ) - self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self) - self.device_table_view_dock.setWidget(self.device_table_view) - - # Device Config View widget - self.dm_config_view = DMConfigView(self) - self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self) - self.dm_config_view_dock.setWidget(self.dm_config_view) - - # Docstring View - self.dm_docs_view = DocstringView(self) - self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self) - self.dm_docs_view_dock.setWidget(self.dm_docs_view) - - # Ophyd Test view - self.ophyd_test_view = DMOphydTest(self) - self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) - self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) - - # Help Inspector - widget = QWidget(self) - layout = QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - self.help_inspector = HelpInspector(self) - layout.addWidget(self.help_inspector) - text_box = QTextEdit(self) - text_box.setReadOnly(False) - text_box.setPlaceholderText("Help text will appear here...") - layout.addWidget(text_box) - self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) - self.help_inspector_dock.setWidget(widget) - - # Register callback - self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) - - # Error Logs View - self.error_logs_view = QTextEdit(self) - self.error_logs_view.setReadOnly(True) - self.error_logs_view.setPlaceholderText("Error logs will appear here...") - self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self) - self.error_logs_dock.setWidget(self.error_logs_view) - self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown) - - # Arrange widgets within the QtAds dock manager - # Central widget area - self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) - # Right area - should be pushed into view if something is active - self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, - self.ophyd_test_dock_view, - self.central_dock_area, - ) - # create bottom area (2-arg -> area) - self.bottom_dock_area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock - ) - - # YAML view left of docstrings (docks relative to bottom area) - self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area - ) - - # Error/help area right of docstrings (dock relative to bottom area) - area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, - self.help_inspector_dock, - self.bottom_dock_area, - ) - self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) - - for dock in self.dock_manager.dockWidgets(): - dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False) - dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False) - dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False) - - # Apply stretch after the layout is done - self.set_default_view([2, 8, 2], [7, 3]) - - for signal, slots in [ - ( - self.device_table_view.selected_devices, - (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - ), - ( - self.ophyd_test_view.device_validated, - (self.device_table_view.update_device_validation,), - ), - ( - self.device_table_view.device_configs_changed, - (self.ophyd_test_view.change_device_configs,), - ), - ]: - for slot in slots: - signal.connect(slot) - - # Once available resource is ready, add it to the view again - if AVAILABLE_RESOURCE_IS_READY: - # Available Resources Widget - self.available_devices = AvailableDeviceResources( - self, shared_selection_signal=self._shared_selection - ) - self.available_devices_dock = QtAds.CDockWidget( - self.dock_manager, "Available Devices", self - ) - self.available_devices_dock.setWidget(self.available_devices) - # Connect slots for available reosource - for signal, slots in [ - ( - self.available_devices.selected_devices, - (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - ), - ( - self.device_table_view.device_configs_changed, - (self.available_devices.mark_devices_used,), - ), - ( - self.available_devices.add_selected_devices, - (self.device_table_view.add_device_configs,), - ), - ( - self.available_devices.del_selected_devices, - (self.device_table_view.remove_device_configs,), - ), - ]: - for slot in slots: - signal.connect(slot) - - # Add toolbar - self._add_toolbar() - - def _add_toolbar(self): - self.toolbar = ModularToolBar(self) - - # Add IO actions - self._add_io_actions() - self._add_table_actions() - self.toolbar.show_bundles(["IO", "Table"]) - self._root_layout.insertWidget(0, self.toolbar) - - def _add_io_actions(self): - # Create IO bundle - io_bundle = ToolbarBundle("IO", self.toolbar.components) - - # Load from disk - load = MaterialIconAction( - text_position="under", - icon_name="file_open", - parent=self, - tooltip="Load configuration file from disk", - label_text="Load Config", - ) - self.toolbar.components.add_safe("load", load) - load.action.triggered.connect(self._load_file_action) - io_bundle.add_action("load") - - # Add safe to disk - save_to_disk = MaterialIconAction( - text_position="under", - icon_name="file_save", - parent=self, - tooltip="Save config to disk", - label_text="Save Config", - ) - self.toolbar.components.add_safe("save_to_disk", save_to_disk) - save_to_disk.action.triggered.connect(self._save_to_disk_action) - io_bundle.add_action("save_to_disk") - - # Add load config from redis - load_redis = MaterialIconAction( - text_position="under", - icon_name="cached", - parent=self, - tooltip="Load current config from Redis", - label_text="Get Current Config", - ) - load_redis.action.triggered.connect(self._load_redis_action) - self.toolbar.components.add_safe("load_redis", load_redis) - io_bundle.add_action("load_redis") - - # Update config action - update_config_redis = MaterialIconAction( - text_position="under", - icon_name="cloud_upload", - parent=self, - tooltip="Update current config in Redis", - label_text="Update Config", - ) - update_config_redis.action.setEnabled(False) - update_config_redis.action.triggered.connect(self._update_redis_action) - self.toolbar.components.add_safe("update_config_redis", update_config_redis) - io_bundle.add_action("update_config_redis") - - # Add load config from plugin dir - self.toolbar.add_bundle(io_bundle) - - # Table actions - - def _add_table_actions(self) -> None: - table_bundle = ToolbarBundle("Table", self.toolbar.components) - - # Reset composed view - reset_composed = MaterialIconAction( - text_position="under", - icon_name="delete_sweep", - parent=self, - tooltip="Reset current composed config view", - label_text="Reset Config", - ) - reset_composed.action.triggered.connect(self._reset_composed_view) - self.toolbar.components.add_safe("reset_composed", reset_composed) - table_bundle.add_action("reset_composed") - - # Add device - add_device = MaterialIconAction( - text_position="under", - icon_name="add", - parent=self, - tooltip="Add new device", - label_text="Add Device", - ) - add_device.action.triggered.connect(self._add_device_action) - self.toolbar.components.add_safe("add_device", add_device) - table_bundle.add_action("add_device") - - # Remove device - remove_device = MaterialIconAction( - text_position="under", - icon_name="remove", - parent=self, - tooltip="Remove device", - label_text="Remove Device", - ) - remove_device.action.triggered.connect(self._remove_device_action) - self.toolbar.components.add_safe("remove_device", remove_device) - table_bundle.add_action("remove_device") - - # Rerun validation - rerun_validation = MaterialIconAction( - text_position="under", - icon_name="checklist", - parent=self, - tooltip="Run device validation with 'connect' on selected devices", - label_text="Validate Connection", - ) - rerun_validation.action.triggered.connect(self._rerun_validation_action) - self.toolbar.components.add_safe("rerun_validation", rerun_validation) - table_bundle.add_action("rerun_validation") - - # Add load config from plugin dir - self.toolbar.add_bundle(table_bundle) - - # IO actions - def _coming_soon(self): - return QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) - - @SafeSlot() - def _load_file_action(self): - """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" - try: - plugin_path = plugin_repo_path() - plugin_name = plugin_package_name() - config_path = os.path.join(plugin_path, plugin_name, "device_configs") - except ValueError: - # Get the recovery config path as fallback - config_path = self._get_recovery_config_path() - logger.warning( - f"No plugin repository installed, fallback to recovery config path: {config_path}" - ) - - # Implement the file loading logic here - start_dir = os.path.abspath(config_path) - file_path = self._get_file_path(start_dir, "open_file") - if file_path: - self._load_config_from_file(file_path) - - def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: - if mode == "open_file": - file_path, _ = QFileDialog.getOpenFileName( - self, caption="Select Config File", dir=start_dir - ) - else: - file_path, _ = QFileDialog.getSaveFileName( - self, caption="Save Config File", dir=start_dir - ) - return file_path - - def _load_config_from_file(self, file_path: str): - """ - Load device config from a given file path and update the device table view. - - Args: - file_path (str): Path to the configuration file. - """ - try: - config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] - except Exception as e: - logger.error(f"Failed to load config from file {file_path}. Error: {e}") - return - self._open_config_choice_dialog(config) - - def _open_config_choice_dialog(self, config: List[dict]): - """ - Open a dialog to choose whether to replace or add the loaded config. - - Args: - config (List[dict]): List of device configurations loaded from the file. - """ - dialog = ConfigChoiceDialog(self) - if dialog.exec(): - if dialog.result() == ConfigChoiceDialog.REPLACE: - self.device_table_view.set_device_config(config) - elif dialog.result() == ConfigChoiceDialog.ADD: - self.device_table_view.add_device_configs(config) - - # TODO would we ever like to add the current config to an existing composition - @SafeSlot() - def _load_redis_action(self): - """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" - reply = _yes_no_question( - self, - "Load currently active config", - "Do you really want to discard the current config and reload?", - ) - if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: - self.device_table_view.set_device_config( - self.client.device_manager._get_redis_device_config() - ) - else: - return - - @SafeSlot() - def _update_redis_action(self) -> None | QMessageBox.StandardButton: - """Action to push the current composition to Redis""" - reply = _yes_no_question( - self, - "Push composition to Redis", - "Do you really want to replace the active configuration in the BEC server with the current composition? ", - ) - if reply != QMessageBox.StandardButton.Yes: - return - if self.device_table_view.table.contains_invalid_devices(): - return QMessageBox.warning( - self, "Validation has errors!", "Please resolve before proceeding." - ) - if self.ophyd_test_view.validation_running(): - return QMessageBox.warning( - self, "Validation has not completed.", "Please wait for the validation to finish." - ) - self._push_composition_to_redis() - - def _push_composition_to_redis(self): - config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} - threadpool = QThreadPool.globalInstance() - comm = CommunicateConfigAction(self._config_helper, None, config, "set") - threadpool.start(comm) - - @SafeSlot() - def _save_to_disk_action(self): - """Action for the 'save_to_disk' action to save the current config to disk.""" - # Check if plugin repo is installed... - try: - config_path = self._get_recovery_config_path() - except ValueError: - # Get the recovery config path as fallback - config_path = os.path.abspath(os.path.expanduser("~")) - logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") - - # Implement the file loading logic here - file_path = self._get_file_path(config_path, "save_file") - if file_path: - config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} - with open(file_path, "w") as file: - file.write(yaml.dump(config)) - - # Table actions - @SafeSlot() - def _reset_composed_view(self): - """Action for the 'reset_composed_view' action to reset the composed view.""" - reply = _yes_no_question( - self, - "Clear View", - "You are about to clear the current composed config view, please confirm...", - ) - if reply == QMessageBox.StandardButton.Yes: - self.device_table_view.clear_device_configs() - - # TODO Bespoke Form to add a new device - @SafeSlot() - def _add_device_action(self): - """Action for the 'add_device' action to add a new device.""" - dialog = PresetClassDeviceConfigDialog(parent=self) - dialog.accepted_data.connect(self._add_to_table_from_dialog) - dialog.open() - - @SafeSlot(dict) - def _add_to_table_from_dialog(self, data): - self.device_table_view.add_device_configs([data]) - - @SafeSlot() - def _remove_device_action(self): - """Action for the 'remove_device' action to remove a device.""" - self.device_table_view.remove_selected_rows() + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.device_manager_widget = DeviceManagerWidget(parent=self) + self.set_content(self.device_manager_widget) @SafeSlot() - @SafeSlot(bool) - def _rerun_validation_action(self, connect: bool = True): - """Action for the 'rerun_validation' action to rerun validation on selected devices.""" - configs = self.device_table_view.table.selected_configs() - self.ophyd_test_view.change_device_configs(configs, True, connect) + def on_enter(self) -> None: + """Called after the view becomes current/visible. - ####### Default view has to be done with setting up splitters ######## - def set_default_view( - self, horizontal_weights: list, vertical_weights: list - ): # TODO separate logic for all ads based widgets - """Apply initial weights to every horizontal and vertical splitter. - - Examples: - horizontal_weights = [1, 3, 2, 1] - vertical_weights = [3, 7] # top:bottom = 30:70 - """ - splitters_h = [] - splitters_v = [] - for splitter in self.findChildren(QSplitter): - if splitter.orientation() == Qt.Orientation.Horizontal: - splitters_h.append(splitter) - elif splitter.orientation() == Qt.Orientation.Vertical: - splitters_v.append(splitter) - - def apply_all(): - for s in splitters_h: - set_splitter_weights(s, horizontal_weights) - for s in splitters_v: - set_splitter_weights(s, vertical_weights) - - QTimer.singleShot(0, apply_all) - - def set_stretch( - self, *, horizontal=None, vertical=None - ): # TODO separate logic for all ads based widgets - """Update splitter weights and re-apply to all splitters. - - Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict - for convenience: horizontal roles = {"left","center","right"}, - vertical roles = {"top","bottom"}. + Default implementation does nothing. Override in subclasses. """ + self.device_manager_widget.on_enter() - def _coerce_h(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [ - float(x.get("left", 1)), - float(x.get("center", x.get("middle", 1))), - float(x.get("right", 1)), - ] - return None - def _coerce_v(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [float(x.get("top", 1)), float(x.get("bottom", 1))] - return None - - h = _coerce_h(horizontal) - v = _coerce_v(vertical) - if h is None: - h = [1, 1, 1] - if v is None: - v = [1, 1] - self.set_default_view(h, v) - - def _get_recovery_config_path(self) -> str: - """Get the recovery config path from the log_writer config.""" - # pylint: disable=protected-access - log_writer_config = self.client._service_config.config.get("log_writer", {}) - writer = DeviceConfigWriter(service_config=log_writer_config) - return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) - - -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys - from copy import deepcopy - from bec_lib.bec_yaml_loader import yaml_load + from bec_qthemes import apply_theme from qtpy.QtWidgets import QApplication - from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + from bec_widgets.applications.main_app import BECMainApp app = QApplication(sys.argv) - w = QWidget() - l = QVBoxLayout() - w.setLayout(l) apply_theme("dark") - button = DarkModeButton() - l.addWidget(button) - device_manager_view = DeviceManagerView() - l.addWidget(device_manager_view) - # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" - # cfg = yaml_load(config_path) - # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) - # # config = device_manager_view.client.device_manager._get_redis_device_config() - # device_manager_view.device_table_view.set_device_config(cfg) - w.show() - w.setWindowTitle("Device Manager View") - w.resize(1920, 1080) - # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + device_manager_view = DeviceManagerView() + _app.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=device_manager_view.device_manager_widget, + mini_text="DM", + ) + _app.show() sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py index 8c24a9b95..4129cd96f 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -9,7 +9,9 @@ from bec_qthemes import material_icon from qtpy import QtCore, QtWidgets -from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + DeviceManagerDisplayWidget, +) from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot @@ -18,8 +20,10 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + RPC = False + def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent) + super().__init__(parent=parent, client=client) self.stacked_layout = QtWidgets.QStackedLayout() self.stacked_layout.setContentsMargins(0, 0, 0, 0) self.stacked_layout.setSpacing(0) @@ -27,14 +31,19 @@ def __init__(self, parent=None, client=None): self.setLayout(self.stacked_layout) # Add device manager view - self.device_manager_view = DeviceManagerView() - self.stacked_layout.addWidget(self.device_manager_view) + self.device_manager_display = DeviceManagerDisplayWidget(parent=self, client=self.client) + self.stacked_layout.addWidget(self.device_manager_display) # Add overlay widget self._overlay_widget = QtWidgets.QWidget(self) self._customize_overlay() self.stacked_layout.addWidget(self._overlay_widget) - self.stacked_layout.setCurrentWidget(self._overlay_widget) + self._initialized = False + + def on_enter(self) -> None: + """Called after the widget becomes visible.""" + if self._initialized is False: + self.stacked_layout.setCurrentWidget(self._overlay_widget) def _customize_overlay(self): self._overlay_widget.setAutoFillBackground(True) @@ -60,33 +69,17 @@ def _customize_overlay(self): def _load_config_from_file_clicked(self): """Handle click on 'Load Config From File' button.""" - start_dir = os.path.expanduser("~") - file_path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, caption="Select Config File", dir=start_dir - ) - if file_path: - self._load_config_from_file(file_path) - - def _load_config_from_file(self, file_path: str): - try: - config = yaml_load(file_path) - except Exception as e: - logger.error(f"Failed to load config from file {file_path}. Error: {e}") - return - config_list = [] - for name, cfg in config.items(): - config_list.append(cfg) - config_list[-1]["name"] = name - self.device_manager_view.device_table_view.set_device_config(config_list) - # self.device_manager_view.ophyd_test.on_device_config_update(config) - self.stacked_layout.setCurrentWidget(self.device_manager_view) + self.device_manager_display._load_file_action() + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) @SafeSlot() def _load_config_clicked(self): """Handle click on 'Load Current Config' button.""" config = self.client.device_manager._get_redis_device_config() - self.device_manager_view.device_table_view.set_device_config(config) - self.stacked_layout.setCurrentWidget(self.device_manager_view) + self.device_manager_display.device_table_view.set_device_config(config) + self._initialized = True # Set initialized to True after first load + self.stacked_layout.setCurrentWidget(self.device_manager_display) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/bec_list.py b/bec_widgets/utils/bec_list.py new file mode 100644 index 000000000..f125d04ec --- /dev/null +++ b/bec_widgets/utils/bec_list.py @@ -0,0 +1,93 @@ +from bec_lib.logger import bec_logger +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +logger = bec_logger.logger + + +class BECList(QListWidget): + """List Widget that manages ListWidgetItems with associated widgets.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {} + + def __contains__(self, key: str) -> bool: + return key in self._widget_map + + def add_widget_item(self, key: str, widget: QWidget): + """ + Add a widget to the list, mapping is associated with the given key. + + Args: + key (str): Key to associate with the widget. + widget (QWidget): Widget to add to the list. + """ + if key in self._widget_map: + self.remove_widget_item(key) + + item = QListWidgetItem() + item.setSizeHint(widget.sizeHint()) + self.insertItem(0, item) + self.setItemWidget(item, widget) + self._widget_map[key] = (item, widget) + + def remove_widget_item(self, key: str): + """ + Remove a widget by identifier key. + + Args: + key (str): Key associated with the widget to remove. + """ + if key not in self._widget_map: + return + + item, widget = self._widget_map.pop(key) + row = self.row(item) + self.takeItem(row) + try: + widget.close() + except Exception: + logger.debug(f"Could not close widget properly for key: {key}.") + try: + widget.deleteLater() + except Exception: + logger.debug(f"Could not delete widget properly for key: {key}.") + + def clear_widgets(self): + """Remove and destroy all widget items.""" + for key in list(self._widget_map.keys()): + self.remove_widget_item(key) + self._widget_map.clear() + self.clear() + + def get_widget(self, key: str) -> QWidget | None: + """Return the widget for a given key.""" + entry = self._widget_map.get(key) + return entry[1] if entry else None + + def get_item(self, key: str) -> QListWidgetItem | None: + """Return the QListWidgetItem for a given key.""" + entry = self._widget_map.get(key) + return entry[0] if entry else None + + def get_widgets(self) -> list[QWidget]: + """Return all managed widgets.""" + return [w for _, w in self._widget_map.values()] + + def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None: + """Return the widget associated with a given QListWidgetItem.""" + for itm, widget in self._widget_map.values(): + if itm == item: + return widget + return None + + def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None: + """Return the QListWidgetItem associated with a given widget.""" + for itm, w in self._widget_map.values(): + if w == widget: + return itm + return None + + def get_all_keys(self) -> list[str]: + """Return all keys for managed widgets.""" + return list(self._widget_map.keys()) diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 730fcdce6..2e5864078 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -10,6 +10,8 @@ logger = bec_logger.logger +RAISE_ERROR_DEFAULT = False + def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs): """ @@ -159,7 +161,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name _slot_params = { "popup_error": bool(slot_kwargs.pop("popup_error", False)), "verify_sender": bool(slot_kwargs.pop("verify_sender", False)), - "raise_error": bool(slot_kwargs.pop("raise_error", False)), + "raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)), } def error_managed(method): diff --git a/bec_widgets/widgets/control/device_manager/__init__.py b/bec_widgets/widgets/control/device_manager/__init__.py index e69de29bb..dca7392e7 100644 --- a/bec_widgets/widgets/control/device_manager/__init__.py +++ b/bec_widgets/widgets/control/device_manager/__init__.py @@ -0,0 +1 @@ +from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index bec612eef..d33639770 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -1,4 +1,5 @@ -from .device_table_view import DeviceTableView +# from .device_table_view import DeviceTableView +from .device_table.device_table import DeviceTable from .dm_config_view import DMConfigView -from .dm_docstring_view import DocstringView -from .dm_ophyd_test import DMOphydTest +from .dm_docstring_view import DocstringView, docstring_to_markdown +from .ophyd_validation.ophyd_validation import OphydValidation diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py index b3f720511..8ac82f0b4 100644 --- a/bec_widgets/widgets/control/device_manager/components/constants.py +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -9,64 +9,105 @@ # TODO 882 keep in sync with headers in device_table_view.py HEADERS_HELP_MD: dict[str, str] = { - "status": "\n".join( - [ - "## Status", - "The current status of the device. Can be one of the following values: ", - "### **LOADED** \n The device with the specified configuration is loaded in the current config.", - "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.", - "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.", - "### **VALID** \n The device config is valid, but the connection has not yet been validated.", - "### **INVALID** \n The device config is invalid and can not be loaded to the current config.", - ] - ), - "name": "\n".join(["## Name ", "The name of the device."]), - "deviceClass": "\n".join( - [ - "## Device Class", - "The device class specifies the type of the device. It will be used to create the instance.", - ] - ), - "readoutPriority": "\n".join( - [ - "## Readout Priority", - "The readout priority of the device. Can be one of the following values: ", - "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", - "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", - "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", - "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", - "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", - ] - ), - "deviceTags": "\n".join( - [ - "## Device Tags", - "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", - ] - ), - "enabled": "\n".join( - [ - "## Enabled", - "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", - ] - ), - "readOnly": "\n".join( - ["## Read Only", "Indicator that a device is read-only or can be modified."] - ), - "onFailure": "\n".join( - [ - "## On Failure", - "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", - "### **buffer** \n The device readback will fall back to the last known value.", - "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", - "### **raise** \n The device readback will raise immediately.", - ] - ), - "softwareTrigger": "\n".join( - [ - "## Software Trigger", - "Indicator whether the device receives a software trigger from BEC during a scan.", - ] - ), - "description": "\n".join(["## Description", "A short description of the device."]), + "valid": { + "long": "\n".join( + [ + "## Valid", + "The current configuration status of the device. Can be one of the following values: ", + "### **VALID** \n The device configuration is valid and can be used.", + "### **INVALID** \n The device configuration is invalid.", + "### **UNKNOWN** \n The device configuration has not been validated yet.", + ] + ), + "short": "Validation status of the device configuration.", + }, + "connect": { + "long": "\n".join( + [ + "## Connect", + "The current connection status of the device. Can be one of the following values: ", + "### **CONNECTED** \n The device is connected and in current session.", + "### **CAN_CONNECT** \n The connection to the device has been validated. It's not yet loaded in the current session.", + "### **CANNOT_CONNECT** \n The connection to the device could not be established.", + "### **UNKNOWN** \n The connection status of the device is unknown.", + ] + ), + "short": "Connection status of the device.", + }, + "name": { + "long": "\n".join(["## Name ", "The name of the device."]), + "short": "Name of the device.", + }, + "deviceClass": { + "long": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "short": "Python class for the device.", + }, + "readoutPriority": { + "long": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "short": "Readout priority of the device for scans in BEC.", + }, + "deviceTags": { + "long": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "short": "Tags associated with the device.", + }, + "enabled": { + "long": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "short": "Enabled status of the device.", + }, + "readOnly": { + "long": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "short": "Read-only status of the device.", + }, + "onFailure": { + "long": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "short": "On failure behavior of the device.", + }, + "softwareTrigger": { + "long": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "short": "Software trigger status of the device.", + }, + "description": { + "long": "\n".join(["## Description", "A short description of the device."]), + "short": "Description of the device.", + }, } diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py new file mode 100644 index 000000000..ad25fa05a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py @@ -0,0 +1,519 @@ +"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV""" + +from copy import deepcopy +from typing import Type + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from pydantic import BaseModel +from pydantic_core import PydanticUndefinedType +from qtpy import QtWidgets + +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +class DeviceConfigTemplate(QtWidgets.QWidget): + """ + Device Configuration Template Widget. + Current supported templates follow the structure in + ophyd_devices.interfaces.device_config_templates.ophyd_templates.OPHYD_DEVICE_TEMPLATES. + + Args: + parent (QtWidgets.QWidget, optional) : Parent widget. Defaults to None. + client (BECClient, optional) : BECClient instance. Defaults to None. + template (dict[str, any], optional) : Device configuration template. If None, + the "CustomDevice" template will be used. Defaults to None. + """ + + RPC = False + + def __init__(self, parent=None, template: dict[str, any] = None): + super().__init__(parent=parent) + if template is None: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + self.template = template + self._device_fields = deepcopy(DEVICE_FIELDS) + self._device_config_fields = deepcopy(DEVICE_CONFIG_FIELDS) + self._unknown_device_config_entry: dict[str, any] = {} + + # Dict to store references to input widgets + self._widgets: dict[str, QtWidgets.QWidget] = {} + + # Two column layout + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 0) + layout.setSpacing(2) + self.setLayout(layout) + + # Left hand side, settings, connection and advanced settings + self._left_layout = QtWidgets.QVBoxLayout() + self._left_layout.setContentsMargins(2, 2, 2, 2) + self._left_layout.setSpacing(4) + # Settings box, name | deviceClass | description + self.settings_box = self._create_settings_box() + # Device Config settings box | dynamic fields from deviceConfig + self.connection_settings_box = self._create_connection_settings_box() + # Advanced Control box | readoutPriority | onFailure | softwareTrigger | enabled | readOnly + self.advanced_control_box = self._create_advanced_control_box() + # Add boxes to left layout + self._left_layout.addWidget(self.settings_box) + self._left_layout.addWidget(self.connection_settings_box) + self._left_layout.addWidget(self.advanced_control_box) + layout.addLayout(self._left_layout) + + # Right hand side, advanced settings + self._right_layout = QtWidgets.QVBoxLayout() + self._right_layout.setContentsMargins(2, 2, 2, 2) + self._right_layout.setSpacing(4) + layout.addLayout(self._right_layout) + # Create Additional Settings box + self.additional_settings_box = self.create_additional_settings() + self._right_layout.addWidget(self.additional_settings_box) + + # Set default values + self.reset_to_defaults() + + def _clear_layout(self, layout: QtWidgets.QLayout) -> None: + """Clear a layout recursively. If the layout contains sub-layouts, they will also be cleared.""" + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().close() + item.widget().deleteLater() + if item.layout(): + self._clear_layout(item.layout()) + + def reset_to_defaults(self) -> None: + """Reset all fields to default values.""" + self._widgets.pop("deviceConfig", None) + self._clear_layout(self.connection_settings_box.layout()) + + # Recreate Connection Settings box + layout: QtWidgets.QGridLayout = self.connection_settings_box.layout() + self._fill_connection_settings_box(self.connection_settings_box, layout) + + # Reset Settings and Advanced Control boxes + for field_name, widget in self._widgets.items(): + if field_name in self.template: + self._set_value_for_widget(widget, self.template[field_name]) + else: + self._set_default_entry(field_name, widget) + + def change_template(self, template: dict[str, any]) -> None: + """ + Change the template and update the form fields accordingly. + + Args: + template (dict[str, any]): New device configuration template. + """ + self.template = template + self.reset_to_defaults() + + def get_config_fields(self) -> dict: + """Retrieve the current configuration from the input fields.""" + config: dict[str, any] = {} + for device_entry, widget in self._widgets.items(): + config[device_entry] = self._get_entry_for_widget(widget) + if self._unknown_device_config_entry: + if "deviceConfig" not in config: + config["deviceConfig"] = {} + config["deviceConfig"].update(self._unknown_device_config_entry) + return config + + def set_config_fields(self, config: dict) -> None: + """ + Set the configuration fields based on the provided config dictionary. + + Args: + config (dict): Configuration dictionary to set the fields. + """ + # Clear storage for unknown entries + self._unknown_device_config_entry.clear() + if self.template.get("deviceClass", "") != config.get("deviceClass", ""): + logger.warning( + f"Device class {config.get('deviceClass', '')} does not match template device class {self.template.get('deviceClass', '')}. Using custom device template." + ) + self.change_template(OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]) + else: + self.reset_to_defaults() + self._fill_fields_from_config(config) + + def _fill_fields_from_config(self, model: dict) -> None: + """ + Fill the form fields base on the provided configuration dictionary. + Please note, deviceConfig is handled separately through _fill_connection_settings_box + as this depends on the template used. + + Args: + model (dict): Configuration dictionary to fill the fields. + """ + for key, value in model.items(): + if key == "name": + wid = self._widgets["name"] + wid.setText(value or "") + elif key == "deviceClass": + wid = self._widgets["deviceClass"] + wid.setText(value or "") + if "deviceClass" in self.template: + wid.setEnabled(False) + else: + wid.setEnabled(True) + elif key == "deviceConfig" and isinstance( + self._widgets.get("deviceConfig", None), dict + ): + # If _widgets["deviceConfig"] is a dict, we have individual widgets for each field + for sub_key, sub_value in value.items(): + widget = self._widgets["deviceConfig"].get(sub_key, None) + if widget is None: + logger.warning( + f"Widget for key {sub_key} not found in deviceConfig widgets." + ) + # Store any unknown entry fields + self._unknown_device_config_entry[sub_key] = sub_value + continue + self._set_value_for_widget(widget, sub_value) + else: + widget = self._widgets.get(key, None) + if widget is not None: + self._set_value_for_widget(widget, value) + + def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None: + """ + Set the value for a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to set the value for. + value (any): The value to set. + """ + if isinstance(widget, (ParameterValueWidget)) and isinstance(value, dict): + for param, val in value.items(): + widget.add_parameter_line(param, val) + elif isinstance(widget, DeviceTagsWidget) and isinstance(value, (list, tuple, set)): + for tag in value: + widget.add_parameter_line(tag or "") + elif isinstance(widget, InputLineEdit): + widget.setText(str(value or "")) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(value)) + elif isinstance(widget, LimitInputWidget): + widget.set_limits(value) + elif isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(value) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, QtWidgets.QTextEdit): + widget.setPlainText(str(value or "")) + else: + logger.warning(f"Unsupported widget type for setting value: {type(widget)}") + + def _get_entry_for_widget(self, widget: QtWidgets.QWidget) -> any: + """ + Get the value from a widget based on its type. + + Args: + widget (QtWidgets.QWidget): The widget to get the value from. + Returns: + any: The value retrieved from the widget. + """ + if isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + return widget.parameters() + elif isinstance(widget, InputLineEdit): + return widget.text().strip() + elif isinstance(widget, ToggleSwitch): + return widget.isChecked() + elif isinstance(widget, LimitInputWidget): + return widget.get_limits() + elif isinstance(widget, QtWidgets.QComboBox): + return widget.currentText() + elif isinstance(widget, QtWidgets.QTextEdit): + return widget.toPlainText() + elif isinstance(widget, dict): + result = {} + for sub_entry, sub_widget in widget.items(): + result[sub_entry] = self._get_entry_for_widget(sub_widget) + return result + else: + logger.warning(f"Unsupported widget type for getting entry: {type(widget)}") + return None + + def _create_device_field( + self, field_name: str, field_info: DeviceConfigField | None = None + ) -> tuple[QtWidgets.QLabel, QtWidgets.QWidget]: + """ + Create a device field based on the field name. If field_info is not provided, + a default label and input widget will be created. + + Args: + field_name (str): Name of the field. + field_info (DeviceConfigField | None, optional): Information about the field. Defaults to None. + """ + if field_info is None: + label = QtWidgets.QLabel(field_name, parent=self) + input_widget = QtWidgets.QLineEdit(parent=self) + return label, input_widget + + label_text = field_info.label + label = QtWidgets.QLabel(label_text, parent=self) + if field_info.required: + label_text = label.text() + label_text += " *" + label.setText(label_text) + label.setStyleSheet("font-weight: bold;") + input_widget = field_info.widget_cls(parent=self) + if field_info.placeholder_text: + if hasattr(input_widget, "setPlaceholderText"): + input_widget.setPlaceholderText(field_info.placeholder_text) + if field_info.static: + input_widget.setEnabled(False) + if field_info.validation_callback: + # Attach validation callback if provided + if isinstance(input_widget, InputLineEdit): + input_widget: InputLineEdit + for callback in field_info.validation_callback: + input_widget.register_validation_callback(callback) + if field_info.default is not None: + # Set default value + if isinstance(input_widget, QtWidgets.QLineEdit): + input_widget.setText(str(field_info.default)) + elif isinstance(input_widget, QtWidgets.QTextEdit): + input_widget.setPlainText(str(field_info.default)) + elif isinstance(input_widget, ToggleSwitch): + input_widget.setChecked(bool(field_info.default)) + elif isinstance(input_widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + index = input_widget.findText(field_info.default) + if index != -1: + input_widget.setCurrentIndex(index) + return label, input_widget + + def _create_group_box_with_grid_layout( + self, title: str + ) -> tuple[QtWidgets.QGroupBox, QtWidgets.QGridLayout]: + """Create a group box with a grid layout.""" + box = QtWidgets.QGroupBox(title) + layout = QtWidgets.QGridLayout(box) + layout.setContentsMargins(4, 8, 4, 8) + layout.setSpacing(4) + box.setLayout(layout) + return box, layout + + def _set_default_entry(self, field_name: str, widget: QtWidgets.QWidget) -> None: + """ + Set the default value for a given field in the form based on the Pydantic model. + + Args: + field_name (str): Name of the field. + widget (QtWidgets.QWidget): The widget to set the default value for. + """ + if field_name == "enabled": + widget.setChecked(True) + return + if field_name == "readOnly": + widget.setChecked(False) + return + default = self._get_default_for_device_config_field(field_name) or "" + widget.setEnabled(True) + if isinstance(widget, QtWidgets.QComboBox): + index = widget.findText(default) + if index != -1: + widget.setCurrentIndex(index) + elif isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit)): + widget.setText(str(default)) + elif isinstance(widget, ToggleSwitch): + widget.setChecked(bool(default)) + elif isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)): + widget.clear_widget() + + def _get_default_for_device_config_field(self, field_name: str) -> any: + """ + Get the default value for a given deviceConfig field based on the Pydantic model. + + Args: + field_name (str): Name of the deviceConfig field. + Returns: + any: The default value for the field, or None if not found. + """ + model_properties: dict = DeviceModel.model_json_schema()["properties"] + if field_name in model_properties: + field_info = model_properties[field_name] + default = field_info.get("default", None) + if default: + return default + return None + + ### Box creation methods ### + + def _create_box(self, box_title: str, field_names: list[str]) -> QtWidgets.QGroupBox: + """ + Create a box layout with specific fields. If field_names are in _device_fields, + their corresponding widgets will be used. + """ + # Create box + box, layout = self._create_group_box_with_grid_layout(box_title) + box.setLayout(layout) + + for ii, field_name in enumerate(field_names): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 0) + layout.addWidget(input_widget, ii, 1) + self._widgets[field_name] = input_widget + return box + + def _create_settings_box(self) -> QtWidgets.QGroupBox: + """Create the settings box widget.""" + box = self._create_box("Settings", ["name", "deviceClass", "description"]) + layout = box.layout() + # Set column stretch + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 1) + return box + + def _create_advanced_control_box(self) -> QtWidgets.QGroupBox: + """Create the advanced control box widget.""" + # Set up advanced control box + box = self._create_box("Advanced Control", ["readoutPriority", "onFailure"]) + layout = box.layout() + for ii, field_name in enumerate(["enabled", "readOnly", "softwareTrigger"]): + label, input_widget = self._create_device_field( + field_name, self._device_fields.get(field_name, None) + ) + layout.addWidget(label, ii, 2) + layout.addWidget(input_widget, ii, 3) + self._widgets[field_name] = input_widget + return box + + def _create_connection_settings_box(self) -> QtWidgets.QGroupBox: + """Create the connection settings box widget. These are all entries in the deviceConfig field.""" + box, layout = self._create_group_box_with_grid_layout("Connection Settings") + box = self._fill_connection_settings_box(box, layout) + return box + + def _fill_connection_settings_box( + self, box: QtWidgets.QGroupBox, layout: QtWidgets.QGridLayout + ) -> QtWidgets.QGroupBox: + """Fill the connection settings box based on the deviceConfig template.""" + if not self.template.get("deviceConfig", {}): + widget = ParameterValueWidget(parent=self) + widget.setToolTip( + "Add custom deviceConfig entries as key-value pairs in the tree view." + ) + layout.addWidget(widget, 0, 0) + self._widgets["deviceConfig"] = widget + return box + # If template specifies deviceConfig fields, create them + self._widgets["deviceConfig"] = {} + model: Type[BaseModel] = self.template["deviceConfig"] + for field_name, field in model.model_fields.items(): + field_info = self._device_config_fields.get(field_name, None) + default = field.get_default() + if isinstance(default, PydanticUndefinedType): + default = None + if field_info: + if field.is_required(): + field_info.required = True + if field.description: + field_info.placeholder_text = field.description + if default is not None: + field_info.default = default + label, input_widget = self._create_device_field(field_name, field_info) + row = layout.rowCount() + layout.addWidget(label, row, 0) + layout.addWidget(input_widget, row, 1) + self._widgets["deviceConfig"][field_name] = input_widget + return box + + def create_additional_settings(self) -> QtWidgets.QGroupBox: + """Create the additional settings box widget.""" + box, layout = self._create_group_box_with_grid_layout("Additional Settings") + toolbox = QtWidgets.QToolBox(parent=self) + layout.addWidget(toolbox, 0, 0) + user_parameters_widget = ParameterValueWidget(parent=self) + self._widgets["userParameter"] = user_parameters_widget + toolbox.addItem(user_parameters_widget, "User Parameter") + device_tags_widget = DeviceTagsWidget(parent=self) + toolbox.addItem(device_tags_widget, "Device Tags") + toolbox.setCurrentIndex(1) + self._widgets["deviceTags"] = device_tags_widget + return box + + +if __name__ == """__main__""": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import yaml + from bec_qthemes import apply_theme + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + apply_theme("light") + + class TestWidget(QtWidgets.QWidget): + pass + + w = TestWidget() + w_layout = QtWidgets.QVBoxLayout(w) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(20) + dark_mode_button = DarkModeButton() + w_layout.addWidget(dark_mode_button) + test_motor = "EpicsMotor" + config_form = DeviceConfigTemplate(template=OPHYD_DEVICE_TEMPLATES[test_motor][test_motor]) + w_layout.addWidget(config_form) + button_layout = QtWidgets.QHBoxLayout() + button = QtWidgets.QPushButton("Get Config") + button.clicked.connect( + lambda: print("Device Config:", yaml.dump(config_form.get_config_fields(), indent=4)) + ) + button_layout.addWidget(button) + button2 = QtWidgets.QPushButton("Reset") + button2.clicked.connect(config_form.reset_to_defaults) + button_layout.addWidget(button2) + combo = QtWidgets.QComboBox() + combo_keys = [ + "EpicsMotor", + "EpicsSignal", + "EpicsSignalRO", + "EpicsSignalWithRBV", + "CustomDevice", + ] + combo.addItems(combo_keys) + combo.setCurrentText(test_motor) + + def text_changed(text: str) -> None: + if text.startswith("EpicsMotor"): + if text == "EpicsMotor": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsMotor"][text] + elif text.startswith("EpicsSignal"): + if text == "EpicsSignal": + template = OPHYD_DEVICE_TEMPLATES[text][text] + else: + template = OPHYD_DEVICE_TEMPLATES["EpicsSignal"][text] + else: + template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] + config_form.change_template(template) + + combo.currentTextChanged.connect(text_changed) + button_layout.addWidget(button) + button_layout.addWidget(combo) + w_layout.addLayout(button_layout) + w.resize(1200, 600) + w.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py new file mode 100644 index 000000000..47d389508 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py @@ -0,0 +1,481 @@ +"""Module for custom input widgets used in device configuration templates.""" + +from ast import literal_eval +from typing import Callable + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from pydantic import BaseModel, ConfigDict +from qtpy import QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.scan_control.scan_group_box import ScanDoubleSpinBox +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + +logger = bec_logger.logger + + +def _try_literal_eval(value: any) -> any: + """Consolidated function for literal evaluation of a value.""" + if value in ["true", "True"]: + return True + if value in ["false", "False"]: + return False + if value == "": + return "" + try: + return literal_eval(f"{value}") + except ValueError: + return value + except Exception: + logger.warning(f"Could not literal_eval value: {value}, returning as string") + return value + + +class InputLineEdit(QtWidgets.QLineEdit): + """ + Custom QLineEdit for input fields with validation. + + Args: + parent (QtWidgets.QWidget, optional): Parent widget. Defaults to None. + config_field (str, optional): Configuration field name. Defaults to "no_field_specified" + required (bool, optional): Whether the field is required. Defaults to True. + placeholder_text (str, optional): Placeholder text for the input field. Defaults to "". + """ + + def __init__( + self, + parent=None, + config_field: str = "no_field_specified", + required: bool = True, + placeholder_text: str = "", + ): + super().__init__(parent) + self._config_field = config_field + self._colors = get_accent_colors() + self._required = required + self.textChanged.connect(self._update_input_field_style) + self._validation_callbacks: list[Callable[[bool], str]] = [] + self.setPlaceholderText(placeholder_text) + self._update_input_field_style() + + def register_validation_callback(self, callback: Callable[[str], bool]) -> None: + """ + Register a custom validation callback. + + Args: + callback (Callable[[str], bool]): A function that takes the input string + and returns True if valid, False otherwise. + """ + self._validation_callbacks.append(callback) + + def apply_theme(self, theme: str) -> None: + """Apply the theme to the widget.""" + self._colors = get_accent_colors() + self._update_input_field_style() + + def _update_input_field_style(self) -> None: + """Update the input field style based on validation.""" + name = self.text() + if not self.is_valid_input(name) and self._required is True: + self.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + return + self.setStyleSheet("") + return + + def is_valid_input(self, name: str) -> bool: + """Validate the input string using plugin helper.""" + name = name.strip() # Remove leading/trailing whitespace + # Run registered validation callbacks + for callback in self._validation_callbacks: + try: + valid = callback(name) + except Exception as exc: + logger.warning( + f"Validation callback raised an exception: {exc}. Defaulting to valid" + ) + valid = True + if not valid: + return False + if not self._required: + return True + if not name: + return False + return True + + +class OnFailureComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the onFailure input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["buffer", "retry", "raise"]) + + +class ReadoutPriorityComboBox(QtWidgets.QComboBox): + """Custom QComboBox for the readoutPriority input field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.addItems(["monitored", "baseline", "async", "continuous", "on_request"]) + + +class LimitInputWidget(QtWidgets.QWidget): + """Custom widget for inputting limits as a tuple (min, max).""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent) + self._layout = QtWidgets.QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + + # Colors + self._colors = get_accent_colors() + + self.min_input = ScanDoubleSpinBox(self, arg_name="min_limit", default=0.0) + self.min_input.setPrefix("Min: ") + self.min_input.setEnabled(False) + self.min_input.setRange(-1e12, 1e12) + self._layout.addWidget(self.min_input) + + self.max_input = ScanDoubleSpinBox(self, arg_name="max_limit", default=0.0) + self.max_input.setPrefix("Max: ") + self.max_input.setRange(-1e12, 1e12) + self.max_input.setEnabled(False) + self._layout.addWidget(self.max_input) + + # Add validity checks + self.min_input.valueChanged.connect(self._check_valid_inputs) + self.max_input.valueChanged.connect(self._check_valid_inputs) + + # Add checkbox to enable/disable limits + self.enable_toggle = ToggleSwitch(self) + self.enable_toggle.setToolTip("Enable editing limits") + self.enable_toggle.setChecked(False) + self.enable_toggle.enabled.connect(self._toggle_limits_enabled) + self._layout.addWidget(self.enable_toggle) + + def reset_defaults(self) -> None: + """Reset limits to default values.""" + self.min_input.setValue(0.0) + self.max_input.setValue(0.0) + self.enable_toggle.setChecked(False) + + def _is_valid_limit(self) -> bool: + """Check if the current limits are valid (min < max).""" + return self.min_input.value() <= self.max_input.value() + + def _check_valid_inputs(self) -> None: + """Check if the current inputs are valid and update styles accordingly.""" + if not self._is_valid_limit(): + self.min_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + self.max_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};") + else: + self.min_input.setStyleSheet("") + self.max_input.setStyleSheet("") + + def _toggle_limits_enabled(self, enable: bool) -> None: + """Enable or disable the limit inputs based on the checkbox state.""" + self.min_input.setEnabled(enable) + self.max_input.setEnabled(enable) + + def get_limits(self) -> list[float, float]: + """Return the limits as a list [min, max].""" + min_val = self.min_input.value() + max_val = self.max_input.value() + return [min_val, max_val] + + def set_limits(self, limits: tuple) -> None: + """Set the limits from a tuple (min, max).""" + checked_state = self.enable_toggle.isChecked() + if not checked_state: + self.enable_toggle.setChecked(True) + self.min_input.setValue(limits[0]) + self.max_input.setValue(limits[1]) + self.enable_toggle.setChecked(checked_state) + + +class ParameterValueWidget(QtWidgets.QWidget): + """Custom QTreeWidget for user parameters input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(2) + self.tree_widget.setHeaderLabels(["Parameter", "Value"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + header = self.tree_widget.header() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None, value: str | None = None) -> None: + """Add a new row with editable Parameter/Value QLineEdits.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Parameter field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Parameter") + self.tree_widget.setItemWidget(item, 0, param_edit) + + # Value field + value_edit = QtWidgets.QLineEdit(self.tree_widget) + value_edit.setPlaceholderText("Value") + self.tree_widget.setItemWidget(item, 1, value_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + if value is not None: + value_edit.setText(str(value)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> dict: + """Return all parameters as a dictionary {parameter: value}.""" + result = {} + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + value_edit = self.tree_widget.itemWidget(item, 1) + if param_edit and value_edit: + key = param_edit.text().strip() + val = value_edit.text().strip() + if key and val: + result[key] = _try_literal_eval(val) + return result + + +class DeviceTagsWidget(QtWidgets.QWidget): + """Custom QTreeWidget for deviceTags input field.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.tree_widget = QtWidgets.QTreeWidget(self) + self._layout.addWidget(self.tree_widget) + self.tree_widget.setColumnCount(1) + self.tree_widget.setHeaderLabels(["Tags"]) + self.tree_widget.setIndentation(0) + self.tree_widget.setRootIsDecorated(False) + self._add_tool_buttons() + + def clear_widget(self) -> None: + """Clear all tags.""" + for i in reversed(range(self.tree_widget.topLevelItemCount())): + item = self.tree_widget.topLevelItem(i) + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + def _add_tool_buttons(self) -> None: + """Add tool buttons for adding/removing parameter lines.""" + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(4) + self._layout.addLayout(button_layout) + self._button_add = QtWidgets.QPushButton(self) + self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self._button_add.setToolTip("Add parameter") + self._button_add.clicked.connect(self._add_button_clicked) + button_layout.addWidget(self._button_add) + + self._button_remove = QtWidgets.QPushButton(self) + self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False)) + self._button_remove.setToolTip("Remove selected parameter") + self._button_remove.clicked.connect(self.remove_parameter_line) + button_layout.addWidget(self._button_remove) + + def _add_button_clicked(self, *args, **kwargs) -> None: + """Handle the add button click event.""" + self.add_parameter_line() + + def add_parameter_line(self, parameter: str | None = None) -> None: + """Add a new row with editable Tag QLineEdit.""" + item = QtWidgets.QTreeWidgetItem(self.tree_widget) + self.tree_widget.addTopLevelItem(item) + + # Tag field + param_edit = QtWidgets.QLineEdit(self.tree_widget) + param_edit.setPlaceholderText("Tag") + self.tree_widget.setItemWidget(item, 0, param_edit) + if parameter is not None: + param_edit.setText(str(parameter)) + + def remove_parameter_line(self) -> None: + """Remove the selected row.""" + selected_items = self.tree_widget.selectedItems() + for item in selected_items: + index = self.tree_widget.indexOfTopLevelItem(item) + if index != -1: + self.tree_widget.takeTopLevelItem(index) + + # --------------------------------------------------------------------- + + def parameters(self) -> list[str]: + """Return all parameters as a list of tags.""" + result = [] + for i in range(self.tree_widget.topLevelItemCount()): + item = self.tree_widget.topLevelItem(i) + param_edit = self.tree_widget.itemWidget(item, 0) + if param_edit: + tag = param_edit.text().strip() + if tag: + result.append(tag) + return result + + +# Validation callback for name field +def validate_name(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").isalnum(): + return False + return True + + +# Validation callback for deviceClass field +def validate_device_cls(name: str) -> bool: + """Check that the name does not contain spaces.""" + if " " in name: + return False + if not name.replace("_", "").replace(".", "").isalnum(): + return False + return True + + +def validate_prefix(value: str) -> bool: + """Check that the prefix does not contain spaces.""" + if " " in value: + return False + if not value.replace("_", "").replace(".", "").replace("-", "").replace(":", "").isalnum(): + return False + return True + + +class DeviceConfigField(BaseModel): + """Pydantic model for device configuration fields.""" + + label: str + widget_cls: type[QtWidgets.QWidget] + required: bool = False + static: bool = False + placeholder_text: str | None = None + validation_callback: list[Callable[[str], bool]] | None = None + default: any = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +DEVICE_FIELDS = { + "name": DeviceConfigField( + label="Name", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device name (no spaces or special characters)", + validation_callback=[validate_name], + ), + "deviceClass": DeviceConfigField( + label="Device Class", + widget_cls=InputLineEdit, + required=True, + placeholder_text="Device class (no spaces or special characters)", + validation_callback=[validate_device_cls], + ), + "description": DeviceConfigField( + label="Description", + widget_cls=QtWidgets.QTextEdit, + required=False, + placeholder_text="Short device description", + ), + "enabled": DeviceConfigField( + label="Enabled", widget_cls=ToggleSwitch, required=False, default=True + ), + "readOnly": DeviceConfigField( + label="Read Only", widget_cls=ToggleSwitch, required=False, default=False + ), + "softwareTrigger": DeviceConfigField( + label="Software Trigger", widget_cls=ToggleSwitch, required=False, default=False + ), + "readoutPriority": DeviceConfigField( + label="Readout Priority", widget_cls=ReadoutPriorityComboBox, default="baseline" + ), + "onFailure": DeviceConfigField( + label="On Failure", widget_cls=OnFailureComboBox, default="retry" + ), + "userParameter": DeviceConfigField( + label="User Parameters", widget_cls=ParameterValueWidget, static=False + ), + "deviceTags": DeviceConfigField(label="Device Tags", widget_cls=DeviceTagsWidget, static=False), +} + +DEVICE_CONFIG_FIELDS = { + "prefix": DeviceConfigField( + label="Prefix", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS IOC prefix, e.g. X25DA-ES1-MOT:", + validation_callback=[validate_prefix], + ), + "read_pv": DeviceConfigField( + label="Read PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS read PV: e.g. X25DA-ES1-MOT:GET", + validation_callback=[validate_prefix], + ), + "write_pv": DeviceConfigField( + label="Write PV", + widget_cls=InputLineEdit, + static=False, + placeholder_text="EPICS write PV (if different from read_pv): e.g. X25DA-ES1-MOT:SET", + validation_callback=[validate_prefix], + ), + "limits": DeviceConfigField(label="Limits", widget_cls=LimitInputWidget, static=False), + "DEFAULT": DeviceConfigField(label="DEFAULT FIELD", widget_cls=InputLineEdit, static=False), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py b/bec_widgets/widgets/control/device_manager/components/device_table/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py new file mode 100644 index 000000000..5e7b665c3 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -0,0 +1,1002 @@ +""" +Module for a TableWidget for the device manager view. Row data is encapsulated +in DeviceTableRow entries. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Callable, Iterable, Tuple + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtGui, QtWidgets +from thefuzz import fuzz + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + get_validation_icons, +) + +logger = bec_logger.logger + +_DeviceCfgIter = Iterable[dict[str, Any]] +# DeviceValidationResult: device_config, config_status, connection_status, error_message +_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] + +FUZZY_SEARCH_THRESHOLD = 80 + + +def is_match( + text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool +) -> bool: + """ + Check if the text matches any of the relevant keys in the row data. + + Args: + text (str): The text to search for. + row_data (dict[str, Any]): The row data to search in. + relevant_keys (list[str]): The keys to consider for searching. + enable_fuzzy (bool): Whether to use fuzzy matching. + Returns: + bool: True if a match is found, False otherwise. + """ + for key in relevant_keys: + data = str(row_data.get(key, "") or "") + if enable_fuzzy: + match_ratio = fuzz.partial_ratio(text.lower(), data.lower()) + if match_ratio >= FUZZY_SEARCH_THRESHOLD: + return True + else: + if text.lower() in data.lower(): + return True + return False + + +class TableSortOnHold: + """Context manager for putting table sorting on hold. Works with nested calls.""" + + def __init__(self, table: QtWidgets.QTableWidget) -> None: + self.table = table + self._call_depth = 0 + self._registered_methods = [] + + def register_on_hold_method( + self, method: Callable[[QtWidgets.QTableWidget, bool], None] + ) -> None: + """ + Register a method to be called when sorting is put on hold. + + Args: + method (Callable[[QtWidgets.QTableWidget, bool], None]): The method to register. + The method should accept the QTableWidget and a bool indicating + whether sorting is being enabled (True) or disabled (False). + """ + self._registered_methods.append(method) + + def __enter__(self): + """Enter the context manager""" + self._call_depth += 1 # Needed for nested calls + self.table.setSortingEnabled(False) + for method in self._registered_methods: + method(self.table, False) + + def __exit__(self, *exc): + """Exit the context manager""" + self._call_depth -= 1 # Remove nested calls + if self._call_depth == 0: # Only re-enable sorting on outermost exit + self.table.setSortingEnabled(True) + for method in self._registered_methods: + method(self.table, True) + + +class CenterIconDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to center icons in table cells.""" + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + # First draw the default cell (without icon) + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.icon = QtGui.QIcon() # Create empty icon to avoid default to be drawn at given position + option.widget.style().drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, option.widget + ) + # Check if there is an icon to draw + icon = index.data(QtCore.Qt.ItemDataRole.DecorationRole) + if not icon: + return + # Draw the icon centered in the cell + icon_size = option.decorationSize + if icon_size.isValid(): + size = icon_size + else: + size = icon.actualSize(option.rect.size()) + + x = option.rect.x() + (option.rect.width() - size.width()) // 2 + y = option.rect.y() + (option.rect.height() - size.height()) // 2 + + icon.paint(painter, QtCore.QRect(QtCore.QPoint(x, y), size)) + + +class CheckBoxDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate to handle checkbox interactions in the table.""" + + # Signal to indicate a checkbox was clicked + checkbox_clicked = QtCore.Signal(int, int, bool) # row, column, checked + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ): + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + if model and (model.flags(index) & QtCore.Qt.ItemFlag.ItemIsUserCheckable): + old_state = QtCore.Qt.CheckState( + model.data(index, QtCore.Qt.ItemDataRole.CheckStateRole) + ) + new_state = ( + QtCore.Qt.CheckState.Unchecked + if old_state == QtCore.Qt.CheckState.Checked + else QtCore.Qt.CheckState.Checked + ) + model.setData(index, new_state, QtCore.Qt.ItemDataRole.CheckStateRole) + model.setData( + index, + new_state == QtCore.Qt.CheckState.Checked, + QtCore.Qt.ItemDataRole.UserRole, + ) + self.checkbox_clicked.emit( + index.row(), index.column(), new_state == QtCore.Qt.CheckState.Checked + ) + return True + return super().editorEvent(event, model, option, index) + + +class SortTableItem(QtWidgets.QTableWidgetItem): + """Custom TableWidgetItem with hidden __column_data attribute for sorting.""" + + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data < other_data + return super().__lt__(other) + + def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + """Override less-than operator for sorting.""" + if not isinstance(other, QtWidgets.QTableWidgetItem): + return NotImplemented + self_data = self.data(QtCore.Qt.ItemDataRole.UserRole) + other_data = other.data(QtCore.Qt.ItemDataRole.UserRole) + if self_data is not None and other_data is not None: + return self_data > other_data + return super().__gt__(other) + + +class DeviceTable(BECWidget, QtWidgets.QWidget): + """Custom table to display device configurations.""" + + RPC = False # TODO discuss if this should be available for RPC + + # Signal emitted if devices are added (updated) or removed + # - device_configs: List of device configurations. + # - added: True if devices were added/updated, False if removed. + device_configs_changed = QtCore.Signal(list, bool) + # Signal emitted when device selection changes, emits list of selected device configs + selected_devices = QtCore.Signal(list) + # Signal emitted when a device row is double-clicked, emits the device config + device_row_dbl_clicked = QtCore.Signal(dict) + # Signal emitted when the device config is in sync with Redis + device_config_in_sync_with_redis = QtCore.Signal(bool) + + _auto_size_request = QtCore.Signal() + + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent=parent) + self.headers_key_map: dict[str, str] = { + "Valid": "valid", + "Connect": "connect", + "Name": "name", + "Device Class": "deviceClass", + "Readout Priority": "readoutPriority", + "On Failure": "onFailure", + "Device Tags": "deviceTags", + "Description": "description", + "Enabled": "enabled", + "Read Only": "readOnly", + "Software Trigger": "softwareTrigger", + } + + # General attributes + self._icon_size = (18, 18) + self._colors = get_accent_colors() + self._icons = get_validation_icons(self._colors, self._icon_size) + self._check_box_icons = { + "checked": material_icon( + "check_box", size=(24, 24), color=self._colors.default, convert_to_pixmap=False + ), + "unchecked": material_icon( + "check_box_outline_blank", + size=(24, 24), + color=self._colors.default, + convert_to_pixmap=False, + ), + } + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) + + # Table related attributes + self.row_data: dict[str, DeviceTableRow] = {} + self.table = QtWidgets.QTableWidget(self) + self.table_sort_on_hold = TableSortOnHold(self.table) + self._setup_table() + self.table_sort_on_hold.register_on_hold_method(self._resize_table_policy) + self.table_sort_on_hold.register_on_hold_method(self._set_table_signals_on_hold) + + # Search related attributes + self._searchable_keys: list[str] = ["name", "deviceClass", "deviceTags", "description"] + self._hidden_rows: set[int] = set() + self._enable_fuzzy_search: bool = True + self._setup_search() + + # Add components to layout + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) + + # Connect slots + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) + # Install event filter + self.table.installEventFilter(self) + + def cleanup(self): + """Cleanup resources.""" + self.row_data.clear() # Drop references to row data.. + # self._autosize_timer.stop() + super().cleanup() + + # ------------------------------------------------------------------------- + # Custom hooks for table events + # ------------------------------------------------------------------------- + + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ): + """Handle selection changes in the table.""" + rows = set() + for index in selected.indexes(): + row = index.row() + rows.add(row) + selected_configs = [] + for row in rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + cfg = deepcopy(row_data.data) + cfg.pop("name") + selected_configs.append({device_name: cfg}) + self.selected_devices.emit(selected_configs) + + def _on_cell_double_clicked(self, row: int, column: int): + """Handle double-click events on table cells.""" + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + self.device_row_dbl_clicked.emit(row_data.data) + + def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Customize event filtering for table interactions.""" + if source is self.table: + if event.type() == QtCore.QEvent.Type.KeyPress: + if event.key() in (QtCore.Qt.Key.Key_Backspace, QtCore.Qt.Key.Key_Delete): + configs = self.get_selected_device_configs() + if configs: + if self._remove_configs_dialog([cfg["name"] for cfg in configs]): + self.remove_device_configs(configs) + return True # Event handled + if event.key() == QtCore.Qt.Key.Key_Escape: + self.table.clearSelection() + return True # handled + return super().eventFilter(source, event) + + def _on_table_checkbox_clicked(self, row: int, column: int, checked: bool): + """Handle checkbox clicks in the table.""" + name_index = list(self.headers_key_map.values()).index("name") + device_name = self._get_cell_data(row, name_index) + row_data = self.row_data.get(device_name) + if not row_data: + return + row_data.data[self.headers_key_map[list(self.headers_key_map.keys())[column]]] = checked + self._on_device_row_data_changed(row_data.data) + + def _on_device_row_data_changed(self, data: dict): + """Handle data change events from device rows.""" + device_name = data.get("name", None) + cfg = deepcopy(data) + cfg.pop("name") + self.selected_devices.emit([{device_name: cfg}]) + self.device_config_in_sync_with_redis.emit(self._is_config_in_sync_with_redis()) + + def _apply_row_filter(self, text_input: str): + """Apply a filter to the table rows based on the filter text.""" + for row in range(self.table.rowCount()): + device_name = self._get_cell_data(row, 2) # Name column + if not device_name: + continue + row_data = self.row_data.get(device_name) + if not row_data: + continue + if is_match( + text_input, row_data.data, self._searchable_keys, self._enable_fuzzy_search + ): + self.table.setRowHidden(row, False) + self._hidden_rows.discard(row) + else: + self.table.setRowHidden(row, True) + self._hidden_rows.add(row) + + def _state_change_fuzzy_search(self, enabled: int): + """Handle state changes for the fuzzy search toggle.""" + self._enable_fuzzy_search = not bool(enabled) + # Re-apply filter with updated fuzzy search setting + current_text = self.search_input.text() + self._apply_row_filter(current_text) + + # ------------------------------------------------------------------------- + # Custom Dialog + # ------------------------------------------------------------------------- + + def _remove_configs_dialog(self, device_names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + device_names (list[str]): List of device names to be removed. + + Returns: + bool: True if the user confirmed removal, False otherwise. + """ + msg = QtWidgets.QMessageBox(self) + msg.setIcon(QtWidgets.QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{device_names[0]}'?" + if len(device_names) == 1 + else f"Remove {len(device_names)} devices?" + ) + separator = "\n" if len(device_names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(device_names)) + msg.setStandardButtons( + QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel + ) + msg.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + + res = msg.exec_() + if res == QtWidgets.QMessageBox.StandardButton.Ok: + return True + return False + + # ------------------------------------------------------------------------- + # Setup table + # ------------------------------------------------------------------------- + def _setup_table(self): + """Initializes the table configuration and headers.""" + # Temporary instance to get headers dynamically + headers = list(self.headers_key_map.keys()) + self.table.setColumnCount(len(headers)) + self.table.setHorizontalHeaderLabels(headers) + # Smooth scrolling + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + + # Hide vertical header + self.table.verticalHeader().setVisible(False) + + # Column resize policies + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(4, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(6, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(7, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(8, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(9, QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(10, QtWidgets.QHeaderView.ResizeMode.Fixed) + + for sizes, col in [ + (0, 85), + (1, 85), + (2, 200), + (3, 200), + (6, 200), + (7, 200), + (8, 90), + (9, 90), + (10, 120), + ]: + self.table.setColumnWidth(sizes, col) + + # Ensure column widths stay fixed + header.setStretchLastSection(False) + + # Sorting + self.table.setSortingEnabled(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(2, QtCore.Qt.SortOrder.AscendingOrder) # Default sort by name + + # Selection behavior + self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.table.horizontalHeader().setHighlightSections(False) + + # Set delegate for checkboxes + checkbox_delegate = CheckBoxDelegate(self.table) + icon_delegate = CenterIconDelegate(self.table) + self.table.setItemDelegateForColumn(0, icon_delegate) # Config status + self.table.setItemDelegateForColumn(1, icon_delegate) # Connection status + self.table.setWordWrap(True) + for col in (8, 9, 10): # enabled, readOnly, softwareTrigger + self.table.setItemDelegateForColumn(col, checkbox_delegate) + checkbox_delegate.checkbox_clicked.connect(self._on_table_checkbox_clicked) + + def _set_table_signals_on_hold(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable table signals.""" + if enable: + table.blockSignals(False) + else: + table.blockSignals(True) + + def _resize_table_policy(self, table: QtWidgets.QTableWidget, enable: bool): + """Enable or disable column resizing.""" + if enable: + table.resizeColumnToContents(2) # Name + table.resizeColumnToContents(3) # Device Class + # table.resizeRowsToContents() + + def _setup_search(self): + """Create components related to the search functionality""" + + # Create search bar + self.search_layout = QtWidgets.QHBoxLayout() + self.search_label = QtWidgets.QLabel("Search:") + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText("Filter devices (approximate matching)...") + self.search_input.setClearButtonEnabled(True) + self.search_input.textChanged.connect(self._apply_row_filter) + self.search_layout.addWidget(self.search_label) + self.search_layout.addWidget(self.search_input) + + # Add exact match toggle + self.fuzzy_layout = QtWidgets.QHBoxLayout() + self.fuzzy_label = QtWidgets.QLabel("Exact Match:") + self.fuzzy_is_disabled = QtWidgets.QCheckBox() + + self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search) + self.fuzzy_is_disabled.setToolTip( + "Enable approximate matching (OFF) and exact matching (ON)" + ) + self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") + self.fuzzy_layout.addWidget(self.fuzzy_label) + self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) + self.fuzzy_layout.addStretch() + + # Add both search components to the layout + self.search_controls = QtWidgets.QHBoxLayout() + self.search_controls.addLayout(self.search_layout) + self.search_controls.addSpacing(20) # Add some space between the search box and toggle + self.search_controls.addLayout(self.fuzzy_layout) + QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + + # ------------------------------------------------------------------------- + # Row Management, internal methods. + # ------------------------------------------------------------------------- + + def _add_row( + self, + data: dict, + config_status: ConfigStatus | int, + connection_status: ConnectionStatus | int, + ): + """ + Adds a new row at the bottom and populates it with data. The row widgets + are stored in self.row_widgets for easy access. Consider to disable sorting + when adding rows as this method is not responsible for maintaining sort order. + + Args: + data (dict): The device data to populate the row. + config_status (ConfigStatus | int): The configuration validation status. + connection_status (ConnectionStatus | int): The connection status. + """ + with self.table_sort_on_hold: + if data["name"] in self.row_data: + logger.warning(f"Overwriting existing device row for {data['name']}") + self._remove_rows_by_name([data["name"]]) + row_index = self.table.rowCount() + self.table.insertRow(row_index) + + # Create row for the table + device_row = DeviceTableRow(data=data) + device_row.set_validation_status(config_status, connection_status) + + # Populate cells + self._populate_device_row_cells(row_index, device_row) + + def _populate_device_row_cells(self, row: int, device_row: DeviceTableRow): + """Populate the cells of a given row with the widgets from the DeviceTableRow.""" + with self.table_sort_on_hold: + config_status, connect_status = device_row.validation_status + column_keys = list(self.headers_key_map.values()) + for ii, key in enumerate(column_keys): + if key in ("enabled", "readOnly", "softwareTrigger"): # flags for checkboxes + item = SortTableItem() + item.setFlags( + item.flags() + | QtCore.Qt.ItemFlag.ItemIsUserCheckable + | QtCore.Qt.ItemFlag.ItemIsEnabled + ) + item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + elif key in ("valid", "connect"): # status columns + item = SortTableItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignCenter | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + item.setIcon( + self._icons["connection_status"][connect_status] + if key == "connect" + else self._icons["config_status"][config_status] + ) + else: + item = QtWidgets.QTableWidgetItem() + item.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + self.table.setItem(row, ii, item) # +2 offset for status columns + self.__update_device_row_data(row, device_row.data) + + def __update_device_row_data(self, row: int, data: dict): + """ + Update an existing device row with new data. + + Args: + row (int): The row index to update. + data (dict): The device data to populate the row. + """ + # Update stored row data + if data["name"] in self.row_data: + self.row_data[data["name"]].set_data(data) + else: + self.row_data[data["name"]] = DeviceTableRow(data) + # Update table cells + with self.table_sort_on_hold: + column_keys = list(self.headers_key_map.values()) # map columns + for key, value in data.items(): + if key not in column_keys: + continue # Skip userParameters and deviceConfig + column = column_keys.index(key) + item = self.table.item(row, column) + if not item: + continue + if key in ("enabled", "readOnly", "softwareTrigger"): + item.setCheckState( + QtCore.Qt.CheckState.Checked if value else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, value) + item.setText("") # No text for checkboxes + elif key == "deviceTags": + item.setText( + ", ".join(value) if isinstance(value, (list, set, tuple)) else str(value) + ) + elif key == "deviceClass": + item.setText( + value.split(".")[-1] + ) # Only show the DeviceClass, not the full module + else: + if value is None: + value = "" + item.setText(str(value)) + self._update_device_row_status( + row, + self.row_data[data["name"]].validation_status[0], + self.row_data[data["name"]].validation_status[1], + ) + self.table.resizeRowToContents(row) + self._on_device_row_data_changed(self.row_data[data["name"]].data) + return True + + def _update_device_row_status( + self, row: int, config_status: int, connection_status: int + ) -> bool: + """ + Update an existing device row's validation status. + + Args: + device_name (str): The name of the device. + config_status (int): The configuration validation status. + connection_status (int): The connection status. + """ + with self.table_sort_on_hold: + item = self.table.item(row, 0) # Config status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, config_status) + item.setIcon(self._icons["config_status"][config_status]) + item = self.table.item(row, 1) # Connect status column + if item: + item.setData(QtCore.Qt.ItemDataRole.UserRole, connection_status) + item.setIcon(self._icons["connection_status"][connection_status]) + + # Update the stored row data as well + device_name = self._get_cell_data(row, 2) # Name column + device_row = self.row_data.get(device_name, None) + if not device_row: + return False + device_row: DeviceTableRow + device_row.set_validation_status(config_status, connection_status) + return True + + def _get_cell_data(self, row: int, column: int) -> str | bool | None: + """ + Get the data from a specific cell. + + Args: + row (int): The row index. + column (int): The column index. + """ + item = self.table.item(row, column) + if item is None: + return None + if column in (8, 9, 10): # Checkboxes + return item.checkState() == QtCore.Qt.CheckState.Checked + return item.text() + + def _update_row(self, data: dict) -> int | None: + """ + Update an existing row with new data. + + Args: + data (dict): The device data to populate the row. + Returns: + int | None: The row index if updated, else None. + """ + device_row = self.row_data.get(data.get("name"), {}) + if self._compare_configs(device_row.data, data): + return None # No update needed + row = self._find_row_by_name(data.get("name", "")) + if row is not None: + self.__update_device_row_data(row, data) + return row + + def _compare_configs(self, cfg1: dict, cfg2: dict) -> bool: + """Compare two device configurations for equality.""" + try: + cfg1_model = DeviceModel.model_validate(cfg1) + cfg2_model = DeviceModel.model_validate(cfg2) + return cfg1_model == cfg2_model + except Exception as e: + logger.error(f"Error comparing device configs: {e}") + return False + + def _clear_table(self): + """Remove all rows.""" + with self.table_sort_on_hold: + n_rows = self.table.rowCount() + for _ in range(n_rows): + self.table.removeRow(0) + self.row_data.clear() + + def _find_row_by_name(self, name: str) -> int | None: + """ + Find a row by device name. + + Args: + name (str): The name of the device to find. + Returns: + int | None: The row index if found, else None. + """ + for row in range(self.table.rowCount()): + data = self._get_cell_data(row, 2) + if data and data == name: + return row + return None + + def _remove_rows_by_name(self, device_names: list[str]): + """ + Remove a row by device name. + + Args: + device_name (str): The name of the device to remove. + """ + if not device_names: + return + with self.table_sort_on_hold: + for device_name in device_names: + row = self._find_row_by_name(device_name) + if row is None: + logger.warning(f"Device {device_name} not found in table for removal.") + return + self.table.removeRow(row) + self.row_data.pop(device_name, None) + + def _is_config_in_sync_with_redis(self): + """Check if the current config is in sync with Redis.""" + if ( + not self.client + or not self.client.device_manager + or not self.client.device_manager.devices + ): + return False # No proper client connection + redis_config = [ + DeviceModel.model_validate(device._config) + for device in self.client.device_manager.devices.values() + ] + try: + current_config = [ + DeviceModel.model_validate(row_data.data) for row_data in self.row_data.values() + ] + if redis_config == current_config: + return True + else: + return False + except Exception as e: + logger.error(f"Error comparing device configs: {e}") + return False + + # ------------------------------------------------------------------------- + # Public API to manage device configs in the table + # ------------------------------------------------------------------------- + + def get_device_config(self) -> list[dict]: + """ + Get the current device configurations in the table. + + Returns: + list[dict]: The list of device configurations. + """ + cfgs = [ + {"name": device_name, **row_data.data} + for device_name, row_data in self.row_data.items() + ] + return cfgs + + def get_validation_results(self) -> dict[str, Tuple[dict, int, int]]: + """ + Get the current device validation results in the table. + + Returns: + dict[str, Tuple[dict, int, int]]: Dictionary mapping of device name to + (device config, config status, connection status). + """ + return { + row_data.data.get("name"): (row_data.data, *row_data.validation_status) + for row_data in self.row_data.values() + if row_data.data.get("name") is not None + } + + def get_selected_device_configs(self) -> list[dict]: + """ + Get the currently selected device configurations in the table. + + Returns: + list[dict]: The list of selected device configurations. + """ + selected_configs = [] + selected_rows = set() + for index in self.table.selectionModel().selectedIndexes(): + selected_rows.add(index.row()) + for row in selected_rows: + device_name = self._get_cell_data(row, 2) # Name column + if device_name: + row_data = self.row_data.get(device_name) + if row_data: + selected_configs.append(row_data.data) + return selected_configs + + # ------------------------------------------------------------------------- + # Public API to be called via signals/slots + # ------------------------------------------------------------------------- + + @SafeSlot(list) + def set_device_config(self, device_configs: _DeviceCfgIter): + """ + Set the device config. This will clear any existing configs. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to set. + """ + self.set_busy(True, text="Loading device configurations...") + with self.table_sort_on_hold: + self.clear_device_configs() + cfgs_added = [] + for cfg in device_configs: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + cfgs_added.append(cfg) + self.device_configs_changed.emit(cfgs_added, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot() + def clear_device_configs(self): + """Clear the device configs.""" + self.set_busy(True, text="Clearing device configurations...") + device_configs = self.get_device_config() + with self.table_sort_on_hold: + self._clear_table() + self.device_configs_changed.emit(device_configs, False) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): + """ + Add devices to the config. If a device already exists, it will be replaced. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to add. + """ + self.set_busy(True, text="Adding device configurations...") + already_in_table = [] + not_in_table = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] in self.row_data: + already_in_table.append(cfg) + else: + not_in_table.append(cfg) + with self.table_sort_on_hold: + # Remove existing rows first + if len(already_in_table) > 0: + self._remove_rows_by_name([cfg["name"] for cfg in already_in_table]) + self.device_configs_changed.emit(already_in_table, False) + + all_configs = already_in_table + not_in_table + if len(all_configs) > 0: + for cfg in already_in_table + not_in_table: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + + self.device_configs_changed.emit(already_in_table + not_in_table, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def update_device_configs(self, device_configs: _DeviceCfgIter): + """ + Update devices in the config. If a device does not exist, it will be added. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to update. + """ + self.set_busy(True, text="Loading device configurations...") + cfgs_updated = [] + with self.table_sort_on_hold: + for cfg in device_configs: + if cfg["name"] not in self.row_data: + self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + cfgs_updated.append(cfg) + continue + # Update existing row if device config has changed + row = self._update_row(cfg) + if row is not None: + cfgs_updated.append(cfg) + self.device_configs_changed.emit(cfgs_updated, True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): + """ + Remove devices from the config. + + Args: + device_configs (dict[str, dict]): The device configs to remove. + """ + self.set_busy(True, text="Removing device configurations...") + cfgs_to_be_removed = list(device_configs) + with self.table_sort_on_hold: + self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed]) + self.device_configs_changed.emit(cfgs_to_be_removed, False) # + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(str) + def remove_device(self, device_name: str): + """ + Remove a device from the config. + + Args: + device_name (str): The name of the device to remove. + """ + self.set_busy(True, text=f"Removing device configuration for {device_name}...") + row_data = self.row_data.get(device_name) + if not row_data: + logger.warning(f"Device {device_name} not found in table for removal.") + self.set_busy(False, text="") + return + with self.table_sort_on_hold: + self._remove_rows_by_name([row_data.data["name"]]) + cfgs = [{"name": device_name, **row_data.data}] + self.device_configs_changed.emit(cfgs, False) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") + + @SafeSlot(list) + def update_multiple_device_validations(self, validation_results: _ValidationResultIter): + """ + Slot to update multiple device validation statuses. This is recommended and more + efficient than updating individual device validation statuses which may affect + the performance of the UI when many devices are being updated in quick succession. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to update. + """ + self.set_busy(True, text="Updating device validations in session...") + self.table.setSortingEnabled(False) + for cfg, config_status, connection_status, _ in validation_results: + row = self._find_row_by_name(cfg.get("name", "")) + if row is None: + logger.warning(f"Device {cfg.get('name')} not found in table for session update.") + continue + self._update_device_row_status(row, config_status, connection_status) + self.table.setSortingEnabled(True) + self.set_busy(False, text="") + + @SafeSlot(dict, int, int, str) + def update_device_validation( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ) -> None: + """ + Update the validation status of a device. If multiple devices are being updated in a batch, + consider using the `update_multiple_device_validations` method instead for better performance. + + Args: + + """ + self.set_busy(True, text="Updating device validation status...") + row = self._find_row_by_name(device_config.get("name", "")) + if row is None: + logger.warning( + f"Device {device_config.get('name')} not found in table for validation update." + ) + self.set_busy(False, text="") + return + # Disable here sorting without context manager to avoid triggering of registered + # resizing methods. Those can be quite heavy, thus, should not run on every + # update of a validation status. + self.table.setSortingEnabled(False) + self._update_device_row_status(row, config_status, connection_status) + self.table.setSortingEnabled(True) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + self.set_busy(False, text="") diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py new file mode 100644 index 000000000..4a777e08a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table_row.py @@ -0,0 +1,56 @@ +"""Module with custom table row for the device manager device table view.""" + +from bec_lib.atlas_models import Device as DeviceModel + +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, +) + + +class DeviceTableRow: + """ + Custom class to hold data and validation status for a device table row. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + + def __init__(self, data: list[str, dict] | None = None): + """Initialize the DeviceTableRow with optional data. + + Args: + data (list[str, dict] | None): Initial data for the row. + """ + self._data = {} + self.validation_status: tuple[int, int] = (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + self.set_data(data or {}) + + @property + def data(self) -> dict: + """Get the current data from the row widgets as a dictionary.""" + return self._data + + def set_data(self, data: DeviceModel | dict) -> None: + """Set the data for the row widgets.""" + if isinstance(data, dict): + data = DeviceModel.model_validate(data) + old_data = DeviceModel.model_validate(self._data) if self._data else None + if old_data is not None and old_data == data: + return # No change needed + self._data = data.model_dump() + self.set_validation_status(ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + + def set_validation_status( + self, valid: ConfigStatus | int, connect_status: ConnectionStatus | int + ) -> None: + """ + Set the validation and connection status icons. + + Args: + valid (ConfigStatus | int): The configuration validation status. + connect_status (ConnectionStatus | int): The connection status. + """ + valid = int(valid) + connect_status = int(connect_status) + self.validation_status = valid, connect_status diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py deleted file mode 100644 index 886b02c78..000000000 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ /dev/null @@ -1,1129 +0,0 @@ -"""Module with the device table view implementation.""" - -from __future__ import annotations - -import copy -import json -import textwrap -from contextlib import contextmanager -from functools import partial -from typing import TYPE_CHECKING, Any, Iterable, List, Literal -from uuid import uuid4 - -from bec_lib.atlas_models import Device -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox -from thefuzz import fuzz - -from bec_widgets.utils.bec_signal_proxy import BECSignalProxy -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal -from bec_widgets.widgets.control.device_manager.components.constants import ( - HEADERS_HELP_MD, - MIME_DEVICE_CONFIG, -) -from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus - -if TYPE_CHECKING: # pragma: no cover - from bec_qthemes._theme import AccentColors - -logger = bec_logger.logger - -_DeviceCfgIter = Iterable[dict[str, Any]] - -# Threshold for fuzzy matching, careful with adjusting this. 80 seems good -FUZZY_SEARCH_THRESHOLD = 80 - -# -USER_CHECK_DATA_ROLE = 101 - - -class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): - """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - - def helpEvent( - self, - event: QtCore.QEvent, - view: QtWidgets.QAbstractItemView, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - ): - """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.Type.ToolTip: - return super().helpEvent(event, view, option, index) - model: DeviceFilterProxyModel = index.model() - model_index = model.mapToSource(index) - row_dict = model.sourceModel().get_row_data(model_index) - description = row_dict.get("description", "") - QtWidgets.QToolTip.showText(event.globalPos(), description, view) - return True - - -class CustomDisplayDelegate(DictToolTipDelegate): - _paint_test_role = Qt.ItemDataRole.DisplayRole - - def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: - return "" - - def _test_custom_paint( - self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex - ): - v = index.model().data(index, self._paint_test_role) - return (v is not None), v - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Any, - ): ... - - def paint( - self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex - ) -> None: - (check, value) = self._test_custom_paint(painter, option, index) - if not check: - return super().paint(painter, option, index) - super().paint(painter, option, index) - painter.save() - self._do_custom_paint(painter, option, index, value) - painter.restore() - - -class WrappingTextDelegate(CustomDisplayDelegate): - """A lightweight delegate that wraps text without expensive size recalculation.""" - - def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6): - super().__init__(parent) - self._parent = parent - self.max_width = max_width - self.margin = margin - self._cache = {} # cache text metrics for performance - self._wrapping_text_columns = None - - @property - def wrapping_text_columns(self) -> List[int]: - # Compute once, cache for later - if self._wrapping_text_columns is None: - self._wrapping_text_columns = [] - view = self._parent - proxy: DeviceFilterProxyModel = self._parent.model() - for col in range(proxy.columnCount()): - delegate = view.itemDelegateForColumn(col) - if isinstance(delegate, WrappingTextDelegate): - self._wrapping_text_columns.append(col) - return self._wrapping_text_columns - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: str, - ): - text = str(value) - if not text: - return - painter.save() - painter.setClipRect(option.rect) - - # Use cached layout if available - cache_key = (text, option.rect.width()) - layout = self._cache.get(cache_key) - if layout is None: - layout = self._compute_layout(text, option) - self._cache[cache_key] = layout - - # Draw text - painter.setPen(option.palette.text().color()) - layout.draw(painter, option.rect.topLeft()) - painter.restore() - - def _compute_layout( - self, text: str, option: QtWidgets.QStyleOptionViewItem - ) -> QtGui.QTextLayout: - """Compute and return the text layout for given text and option.""" - layout = self._get_layout(text, option.font) - text_option = QtGui.QTextOption() - text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere) - layout.setTextOption(text_option) - layout.beginLayout() - height = 0 - max_lines = 100 # safety cap, should never be more than 100 lines.. - for _ in range(max_lines): - line = layout.createLine() - if not line.isValid(): - break - line.setLineWidth(option.rect.width() - self.margin) - line.setPosition(QtCore.QPointF(self.margin / 2, height)) - line_height = line.height() - if line_height <= 0: - break # avoid negative or zero height lines to be added - height += line_height - layout.endLayout() - return layout - - def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout: - return QtGui.QTextLayout(text, font_option) - - def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize: - """Return a cached or approximate height; avoids costly recomputation.""" - text = str(index.data(QtCore.Qt.DisplayRole) or "") - view = self._parent - view.initViewItemOption(option) - if view.isColumnHidden(index.column()) or not view.isVisible() or not text: - return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin) - - # Use cache for consistent size computation - cache_key = (text, self.max_width) - if cache_key in self._cache: - layout = self._cache[cache_key] - height = 0 - for i in range(layout.lineCount()): - height += layout.lineAt(i).height() - return QtCore.QSize(self.max_width, int(height + self.margin)) - - # Approximate without layout (fast path) - metrics = option.fontMetrics - pixel_width = max(self._parent.columnWidth(index.column()), 100) - if pixel_width > 2000: # safeguard against uninitialized columns, may return large values - pixel_width = 100 - char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin) - wrapped_lines = textwrap.wrap(text, width=char_per_line) - lines = len(wrapped_lines) - return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) - - def estimate_chars_per_line( - self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int - ) -> int: - """Estimate number of characters that fit in a line for given width.""" - metrics = option.fontMetrics - elided = metrics.elidedText(text, Qt.ElideRight, column_width) - return len(elided.rstrip("…")) - - @SafeSlot(int, int, int) - @SafeSlot(int) - def _on_section_resized( - self, logical_index: int, old_size: int | None = None, new_size: int | None = None - ): - """Only update rows if a wrapped column was resized.""" - self._cache.clear() - # Make sure layout is computed first - QtCore.QTimer.singleShot(0, self._update_row_heights) - - def _update_row_heights(self): - """Efficiently adjust row heights based on wrapped columns.""" - view = self._parent - proxy = view.model() - option = QtWidgets.QStyleOptionViewItem() - view.initViewItemOption(option) - for row in range(proxy.rowCount()): - max_height = 18 - for column in self.wrapping_text_columns: - index = proxy.index(row, column) - delegate = view.itemDelegateForColumn(column) - hint = delegate.sizeHint(option, index) - max_height = max(max_height, hint.height()) - if view.rowHeight(row) != max_height: - view.setRowHeight(row, max_height) - - -class CenterCheckBoxDelegate(CustomDisplayDelegate): - """Custom checkbox delegate to center checkboxes in table cells.""" - - _paint_test_role = USER_CHECK_DATA_ROLE - - def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): - super().__init__(parent) - colors: AccentColors = colors if colors else get_accent_colors() # type: ignore - _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) - self._icon_checked = _icon("check_box") - self._icon_unchecked = _icon("check_box_outline_blank") - - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) - self._icon_checked = _icon("check_box") - self._icon_unchecked = _icon("check_box_outline_blank") - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Literal[ - Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked - ], - ): - pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked - pix_rect = pixmap.rect() - pix_rect.moveCenter(option.rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) - - def editorEvent( - self, - event: QtCore.QEvent, - model: QtCore.QSortFilterProxyModel, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - ): - if event.type() != QtCore.QEvent.Type.MouseButtonRelease: - return False - current = model.data(index, USER_CHECK_DATA_ROLE) - new_state = ( - Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked - ) - return model.setData(index, new_state, USER_CHECK_DATA_ROLE) - - -class DeviceValidatedDelegate(CustomDisplayDelegate): - """Custom delegate for displaying validated device configurations.""" - - def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): - super().__init__(parent) - colors = colors if colors else get_accent_colors() - _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) - self._icons = { - ValidationStatus.PENDING: _icon(color=colors.default), - ValidationStatus.VALID: _icon(color=colors.success), - ValidationStatus.FAILED: _icon(color=colors.emergency), - } - - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) - self._icons = { - ValidationStatus.PENDING: _icon(color=colors.default), - ValidationStatus.VALID: _icon(color=colors.success), - ValidationStatus.FAILED: _icon(color=colors.emergency), - } - - def _do_custom_paint( - self, - painter: QtGui.QPainter, - option: QtWidgets.QStyleOptionViewItem, - index: QModelIndex, - value: Literal[0, 1, 2], - ): - """ - Paint the validation status icon centered in the cell. - - Args: - painter (QtGui.QPainter): The painter object. - option (QtWidgets.QStyleOptionViewItem): The style options for the item. - index (QModelIndex): The model index of the item. - value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed. - Relates to ValidationStatus enum. - """ - if pixmap := self._icons.get(value): - pix_rect = pixmap.rect() - pix_rect.moveCenter(option.rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) - - -class DeviceTableModel(QtCore.QAbstractTableModel): - """ - Custom Device Table Model for managing device configurations. - - Sort logic is implemented directly on the data of the table view. - """ - - # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed - configs_changed = QtCore.Signal(list, bool) - - def __init__(self, parent: DeviceTableModel | None = None): - super().__init__(parent) - self._device_config: list[dict[str, Any]] = [] - self._validation_status: dict[str, ValidationStatus] = {} - # TODO 882 keep in sync with HEADERS_HELP_MD - self.headers = [ - "status", - "name", - "deviceClass", - "readoutPriority", - "onFailure", - "deviceTags", - "description", - "enabled", - "readOnly", - "softwareTrigger", - ] - self._checkable_columns_enabled = {"enabled": True, "readOnly": True} - self._device_model_schema = Device.model_json_schema() - - ############################################### - ########## Override custom Qt methods ######### - ############################################### - - def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: - return len(self._device_config) - - def columnCount( - self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex() - ) -> int: - return len(self.headers) - - def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): - if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: - if section == 9: # softwareTrigger - return "softTrig" - return self.headers[section] - return None - - def get_row_data(self, index: QtCore.QModelIndex) -> dict: - """Return the row data for the given index.""" - if not index.isValid(): - return {} - return copy.deepcopy(self._device_config[index.row()]) - - def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): - """Return data for the given index and role.""" - if not index.isValid(): - return None - row, col = index.row(), index.column() - - if col == 0 and role == Qt.ItemDataRole.DisplayRole: - dev_name = self._device_config[row].get("name", "") - return self._validation_status.get(dev_name, ValidationStatus.PENDING) - - key = self.headers[col] - value = self._device_config[row].get(key, None) - if value is None: - value = ( - self._device_model_schema.get("properties", {}).get(key, {}).get("default", None) - ) - - if role == Qt.ItemDataRole.DisplayRole: - if key in ("enabled", "readOnly", "softwareTrigger"): - return bool(value) - if key == "deviceTags": - return ", ".join(str(tag) for tag in value) if value else "" - if key == "deviceClass": - return str(value).split(".")[-1] - return str(value) if value is not None else "" - if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"): - return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked - if role == Qt.ItemDataRole.TextAlignmentRole: - if key in ("enabled", "readOnly", "softwareTrigger"): - return Qt.AlignmentFlag.AlignCenter - return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter - if role == Qt.ItemDataRole.FontRole: - font = QtGui.QFont() - return font - return None - - def flags(self, index): - """Flags for the table model.""" - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - key = self.headers[index.column()] - - base_flags = super().flags(index) | ( - Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled - ) - - if key in ("enabled", "readOnly", "softwareTrigger"): - if self._checkable_columns_enabled.get(key, True): - return base_flags | Qt.ItemFlag.ItemIsUserCheckable - else: - return base_flags # disable editing but still visible - return base_flags - - def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: - """ - Method to set the data of the table. - - Args: - index (QModelIndex): The index of the item to modify. - value (Any): The new value to set. - role (Qt.ItemDataRole): The role of the data being set. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if not index.isValid(): - return False - key = self.headers[index.column()] - if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE: - if not self._checkable_columns_enabled.get(key, True): - return False # ignore changes if column is disabled - self._device_config[index.row()][key] = value == Qt.CheckState.Checked - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, USER_CHECK_DATA_ROLE]) - return True - return False - - #################################### - ############ Drag and Drop ######### - #################################### - - def mimeTypes(self) -> List[str]: - return [*super().mimeTypes(), MIME_DEVICE_CONFIG] - - def supportedDropActions(self): - return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction - - def dropMimeData(self, data, action, row, column, parent): - if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: - return False - if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: - return False - self.add_device_configs(json.loads(raw_data.toStdString())) - return True - - #################################### - ############ Public methods ######## - #################################### - - def get_device_config(self) -> list[dict[str, Any]]: - """Method to get the device configuration.""" - return copy.deepcopy(self._device_config) - - def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: - _configs = self._device_config if configs is None else configs - return set(cfg.get("name") for cfg in _configs if cfg.get("name") is not None) # type: ignore - - def _name_exists_in_config(self, name: str, exists: bool): - if (name in self.device_names()) == exists: - return True - return not exists - - def add_device_configs(self, device_configs: _DeviceCfgIter): - """ - Add devices to the model. - - Args: - device_configs (_DeviceCfgList): An iterable of device configurations to add. - """ - already_in_list = [] - added_configs = [] - for cfg in device_configs: - if self._name_exists_in_config(name := cfg.get("name", ""), True): - logger.warning(f"Device {name} is already in the config. It will be updated.") - self.remove_configs_by_name([name]) - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(copy.deepcopy(cfg)) - added_configs.append(cfg) - self.endInsertRows() - self.configs_changed.emit(device_configs, True) - - def remove_device_configs(self, device_configs: _DeviceCfgIter): - """ - Remove devices from the model. - - Args: - device_configs (_DeviceCfgList): An iterable of device configurations to remove. - """ - removed = [] - for cfg in device_configs: - if cfg not in self._device_config: - logger.warning(f"Device {cfg.get('name')} does not exist in the model.") - continue - with self._remove_row(self._device_config.index(cfg)) as row: - removed.append(self._device_config.pop(row)) - self.configs_changed.emit(removed, False) - - def remove_configs_by_name(self, names: Iterable[str]): - configs = filter(lambda cfg: cfg is not None, (self.get_by_name(name) for name in names)) - self.remove_device_configs(configs) # type: ignore # Nones are filtered - - def get_by_name(self, name: str) -> dict[str, Any] | None: - for cfg in self._device_config: - if cfg.get("name") == name: - return cfg - logger.warning(f"Device {name} does not exist in the model.") - return None - - @contextmanager - def _remove_row(self, row: int): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - try: - yield row - finally: - self.endRemoveRows() - - def set_device_config(self, device_configs: _DeviceCfgIter): - """ - Replace the device config. - - Args: - device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. - """ - diff_names = self.device_names(device_configs) - self.device_names() - diff = [cfg for cfg in self._device_config if cfg.get("name") in diff_names] - self.beginResetModel() - self._device_config = copy.deepcopy(list(device_configs)) - self.endResetModel() - self.configs_changed.emit(diff, False) - self.configs_changed.emit(device_configs, True) - - def clear_table(self): - """ - Clear the table. - """ - self.beginResetModel() - self._device_config.clear() - self.endResetModel() - self.configs_changed.emit(self._device_config, False) - - def update_validation_status(self, device_name: str, status: int | ValidationStatus): - """ - Handle device status changes. - - Args: - device_name (str): The name of the device. - status (int): The new status of the device. - """ - if isinstance(status, int): - status = ValidationStatus(status) - if device_name not in self.device_names(): - logger.warning(f"Device {device_name} not found in table") - return - self._validation_status[device_name] = status - row = None - for ii, item in enumerate(self._device_config): - if item["name"] == device_name: - row = ii - break - if row is None: - logger.warning( - f"Device {device_name} not found in device_status dict {self._validation_status}" - ) - return - # Emit dataChanged for column 0 (status column) - index = self.index(row, 0) - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) - - def validation_statuses(self): - return copy.deepcopy(self._validation_status) - - -class BECTableView(QtWidgets.QTableView): - """Table View with custom keyPressEvent to delete rows with backspace or delete key""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.setAcceptDrops(True) - self.setDropIndicatorShown(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) - - def model(self) -> DeviceFilterProxyModel: - return super().model() # type: ignore - - def keyPressEvent(self, event) -> None: - """ - Delete selected rows with backspace or delete key - - Args: - event: keyPressEvent - """ - if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): - return self.delete_selected() - return super().keyPressEvent(event) - - def contains_invalid_devices(self): - return ValidationStatus.FAILED in self.model().sourceModel().validation_statuses().values() - - def all_configs(self): - return self.model().sourceModel().get_device_config() - - def selected_configs(self): - return self.model().get_row_data(self.selectionModel().selectedRows()) - - def delete_selected(self): - proxy_indexes = self.selectionModel().selectedRows() - if not proxy_indexes: - return - model: DeviceTableModel = self.model().sourceModel() # access underlying model - self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - - def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: - """ - Map proxy model indices to source model row indices. - - Args: - proxy_indexes (list[QModelIndex]): List of proxy model indices. - - Returns: - list[int]: List of source model row indices. - """ - proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) - return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) - - def _confirm_and_remove_rows( - self, model: DeviceTableModel, source_rows: list[QModelIndex] - ) -> bool: - """ - Prompt the user to confirm removal of rows and remove them from the model if accepted. - - Returns True if rows were removed, False otherwise. - """ - configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] - names = [cfg.get("name", "") for cfg in configs] - if not names: - logger.warning("No device names found for selected rows.") - return False - if self._remove_rows_msg_dialog(names): - model.remove_device_configs(configs) - return True - return False - - def _remove_rows_msg_dialog(self, names: list[str]) -> bool: - """ - Prompt the user to confirm removal of rows and remove them from the model if accepted. - - Args: - names (list[str]): List of device names to be removed. - - Returns: - bool: True if the user confirmed removal, False otherwise. - """ - msg = QMessageBox(self) - msg.setIcon(QMessageBox.Icon.Warning) - msg.setWindowTitle("Confirm device removal") - msg.setText( - f"Remove device '{names[0]}'?" if len(names) == 1 else f"Remove {len(names)} devices?" - ) - separator = "\n" if len(names) < 12 else ", " - msg.setInformativeText("Selected devices: \n" + separator.join(names)) - msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) - msg.setDefaultButton(QMessageBox.StandardButton.Cancel) - - res = msg.exec_() - if res == QMessageBox.StandardButton.Ok: - return True - return False - - -class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel): - - def __init__(self, parent=None): - super().__init__(parent) - self._hidden_rows = set() - self._filter_text = "" - self._enable_fuzzy = True - self._filter_columns = [1, 2, 6] # name, deviceClass and description for search - self._status_order = { - ValidationStatus.VALID: 0, - ValidationStatus.PENDING: 1, - ValidationStatus.FAILED: 2, - } - - def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: - return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) - - def sourceModel(self) -> DeviceTableModel: - return super().sourceModel() # type: ignore - - def hide_rows(self, row_indices: list[int]): - """ - Hide specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to hide. - """ - self._hidden_rows.update(row_indices) - self.invalidateFilter() - - def lessThan(self, left, right): - """Add custom sorting for the status column""" - if left.column() != 0 or right.column() != 0: - return super().lessThan(left, right) - left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole) - right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole) - return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99) - - def show_rows(self, row_indices: list[int]): - """ - Show specific rows in the model. - - Args: - row_indices (list[int]): List of row indices to show. - """ - self._hidden_rows.difference_update(row_indices) - self.invalidateFilter() - - def show_all_rows(self): - """ - Show all rows in the model. - """ - self._hidden_rows.clear() - self.invalidateFilter() - - @SafeSlot(int) - def disable_fuzzy_search(self, enabled: int): - self._enable_fuzzy = not bool(enabled) - self.invalidateFilter() - - def setFilterText(self, text: str): - self._filter_text = text.lower() - self.invalidateFilter() - - def filterAcceptsRow(self, source_row: int, source_parent) -> bool: - # No hidden rows, and no filter text - if not self._filter_text and not self._hidden_rows: - return True - # Hide hidden rows - if source_row in self._hidden_rows: - return False - # Check the filter text for each row - model = self.sourceModel() - text = self._filter_text.lower() - for column in self._filter_columns: - index = model.index(source_row, column, source_parent) - data = str(model.data(index, Qt.ItemDataRole.DisplayRole) or "") - if self._enable_fuzzy is True: - match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) - if match_ratio >= FUZZY_SEARCH_THRESHOLD: - return True - else: - if text in data.lower(): - return True - return False - - def flags(self, index): - return super().flags(index) | Qt.ItemFlag.ItemIsDropEnabled - - def supportedDropActions(self): - return self.sourceModel().supportedDropActions() - - def mimeTypes(self): - return self.sourceModel().mimeTypes() - - def dropMimeData(self, data, action, row, column, parent): - sp = self.mapToSource(parent) if parent.isValid() else QtCore.QModelIndex() - return self.sourceModel().dropMimeData(data, action, row, column, sp) - - -class DeviceTableView(BECWidget, QtWidgets.QWidget): - """Device Table View for the device manager.""" - - # Selected device configuration list[dict[str, Any]] - selected_devices = QtCore.Signal(list) # type: ignore - # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed - device_configs_changed = QtCore.Signal(list, bool) # type: ignore - - RPC = False - PLUGIN = False - - def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelectionSignal()): - super().__init__(client=client, parent=parent, theme_update=True) - - self._shared_selection_signal = shared_selection_signal - self._shared_selection_uuid = str(uuid4()) - self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) - - self._layout = QtWidgets.QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSpacing(4) - self.setLayout(self._layout) - - # Setup table view - self._setup_table_view() - # Setup search view, needs table proxy to be iniditate - self._setup_search() - # Add widgets to main layout - self._layout.addLayout(self.search_controls) - self._layout.addWidget(self.table) - - # Connect signals - self._model.configs_changed.connect(self.device_configs_changed.emit) - - def get_help_md(self) -> str: - """ - Generate Markdown help for a cell or header. - """ - pos = self.table.mapFromGlobal(QtGui.QCursor.pos()) - model: DeviceTableModel = self._model # access underlying model - index = self.table.indexAt(pos) - if index.isValid(): - column = index.column() - label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) - if label == "softTrig": - label = "softwareTrigger" - return HEADERS_HELP_MD.get(label, "") - return "" - - def _setup_search(self): - """Create components related to the search functionality""" - - # Create search bar - self.search_layout = QtWidgets.QHBoxLayout() - self.search_label = QtWidgets.QLabel("Search:") - self.search_input = QtWidgets.QLineEdit() - self.search_input.setPlaceholderText( - "Filter devices (approximate matching)..." - ) # Default to fuzzy search - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self.proxy.setFilterText) - self.search_layout.addWidget(self.search_label) - self.search_layout.addWidget(self.search_input) - - # Add exact match toggle - self.fuzzy_layout = QtWidgets.QHBoxLayout() - self.fuzzy_label = QtWidgets.QLabel("Exact Match:") - self.fuzzy_is_disabled = QtWidgets.QCheckBox() - - self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search) - self.fuzzy_is_disabled.setToolTip( - "Enable approximate matching (OFF) and exact matching (ON)" - ) - self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)") - self.fuzzy_layout.addWidget(self.fuzzy_label) - self.fuzzy_layout.addWidget(self.fuzzy_is_disabled) - self.fuzzy_layout.addStretch() - - # Add both search components to the layout - self.search_controls = QtWidgets.QHBoxLayout() - self.search_controls.addLayout(self.search_layout) - self.search_controls.addSpacing(20) # Add some space between the search box and toggle - self.search_controls.addLayout(self.fuzzy_layout) - QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) - - def _setup_table_view(self) -> None: - """Setup the table view.""" - # Model + Proxy - self.table = BECTableView(self) - self._model = DeviceTableModel(parent=self.table) - self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self._model) - self.table.setModel(self.proxy) - self.table.setSortingEnabled(True) - - # Delegates - colors = get_accent_colors() - self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) - self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) - self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300) - # Add resize handling for wrapped delegate - header = self.table.horizontalHeader() - - self.table.setItemDelegateForColumn(0, self.validated_delegate) # status - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure - self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description - self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger - - # Disable wrapping, use eliding, and smooth scrolling - self.table.setWordWrap(False) - self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) - self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - - # Column resize policies - header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus - header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name - header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass - header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority - header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure - header.setSectionResizeMode( - 5, QHeaderView.ResizeMode.Interactive - ) # deviceTags: expand to fill - header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill - header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled - header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly - header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger - - self.table.setColumnWidth(0, 70) - self.table.setColumnWidth(5, 200) - self.table.setColumnWidth(6, 200) - self.table.setColumnWidth(7, 70) - self.table.setColumnWidth(8, 70) - self.table.setColumnWidth(9, 70) - - # Ensure column widths stay fixed - header.setMinimumSectionSize(25) - header.setDefaultSectionSize(90) - header.setStretchLastSection(False) - - # Resize policy for wrapped text delegate - self._resize_proxy = BECSignalProxy( - header.sectionResized, - rateLimit=25, - slot=self.wrapped_delegate._on_section_resized, - timeout=1.0, - ) - - # Selection behavior - self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - # Connect to selection model to get selection changes - self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) - self.table.horizontalHeader().setHighlightSections(False) - - # Connect model signals to autosize request - self._model.rowsInserted.connect(self._request_autosize_columns) - self._model.rowsRemoved.connect(self._request_autosize_columns) - self._model.modelReset.connect(self._request_autosize_columns) - self._model.dataChanged.connect(self._request_autosize_columns) - - def remove_selected_rows(self): - self.table.delete_selected() - - def get_device_config(self) -> list[dict[str, Any]]: - """Get the device config.""" - return self._model.get_device_config() - - def apply_theme(self, theme: str | None = None): - self.checkbox_delegate.apply_theme(theme) - self.validated_delegate.apply_theme(theme) - - ###################################### - ########### Slot API ################# - ###################################### - - def _request_autosize_columns(self, *args): - if not hasattr(self, "_autosize_timer"): - self._autosize_timer = QtCore.QTimer(self) - self._autosize_timer.setSingleShot(True) - self._autosize_timer.timeout.connect(self._autosize_columns) - self._autosize_timer.start(0) - - @SafeSlot() - def _autosize_columns(self): - if self._model.rowCount() == 0: - return - for col in (1, 2, 3): - self.table.resizeColumnToContents(col) - - @SafeSlot(str) - def _handle_shared_selection_signal(self, uuid: str): - if uuid != self._shared_selection_uuid: - self.table.clearSelection() - - @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) - def _on_selection_changed( - self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection - ) -> None: - """ - Handle selection changes in the device table. - - Args: - selected (QtCore.QItemSelection): The selected items. - deselected (QtCore.QItemSelection): The deselected items. - """ - self._shared_selection_signal.proc.emit(self._shared_selection_uuid) - if not (selected_configs := list(self.table.selected_configs())): - return - self.selected_devices.emit(selected_configs) - - ###################################### - ##### Ext. Slot API ################# - ###################################### - - @SafeSlot(list) - def set_device_config(self, device_configs: _DeviceCfgIter): - """ - Set the device config. - - Args: - config (Iterable[str,dict]): The device config to set. - """ - self._model.set_device_config(device_configs) - - @SafeSlot() - def clear_device_configs(self): - """Clear the device configs.""" - self._model.clear_table() - - @SafeSlot(list) - def add_device_configs(self, device_configs: _DeviceCfgIter): - """ - Add devices to the config. - - Args: - device_configs (dict[str, dict]): The device configs to add. - """ - self._model.add_device_configs(device_configs) - - @SafeSlot(list) - def remove_device_configs(self, device_configs: _DeviceCfgIter): - """ - Remove devices from the config. - - Args: - device_configs (dict[str, dict]): The device configs to remove. - """ - self._model.remove_device_configs(device_configs) - - @SafeSlot(str) - def remove_device(self, device_name: str): - """ - Remove a device from the config. - - Args: - device_name (str): The name of the device to remove. - """ - self._model.remove_configs_by_name([device_name]) - - @SafeSlot(str, int) - def update_device_validation( - self, device_name: str, validation_status: int | ValidationStatus - ) -> None: - """ - Update the validation status of a device. - - Args: - device_name (str): The name of the device. - validation_status (int | ValidationStatus): The new validation status. - """ - self._model.update_validation_status(device_name, validation_status) - - -if __name__ == "__main__": - import sys - - import numpy as np - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - widget = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - window = DeviceTableView() - layout.addWidget(window) - # QPushButton - button = QtWidgets.QPushButton("Test status_update") - layout.addWidget(button) - - def _button_clicked(): - names = list(window._model.device_names()) - for name in names: - window.update_device_validation( - name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED - ) - - button.clicked.connect(_button_clicked) - # pylint: disable=protected-access - config = window.client.device_manager._get_redis_device_config() - config.insert( - 0, - { - "name": "TestDevice", - "deviceClass": "bec.devices.MockDevice", - "description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.", - "deviceTags": ["test", "mock", "longtagnameexample"], - "enabled": True, - "readOnly": False, - "softwareTrigger": True, - }, - ) - # names = [cfg.pop("name") for cfg in config] - # config_dict = {name: cfg for name, cfg in zip(names, config)} - window.set_device_config(config) - window.resize(1920, 1200) - widget.show() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py index 245080f32..2202efc3d 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -8,42 +8,47 @@ from bec_lib.logger import bec_logger from qtpy import QtCore, QtWidgets -from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger -class DMConfigView(BECWidget, QtWidgets.QWidget): - def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent, theme_update=True) +class DMConfigView(QtWidgets.QWidget): + """Widget to show the config of a selected device in YAML format.""" + + RPC = False + + def __init__(self, parent=None): + super().__init__(parent=parent) self.stacked_layout = QtWidgets.QStackedLayout() self.stacked_layout.setContentsMargins(0, 0, 0, 0) self.stacked_layout.setSpacing(0) self.setLayout(self.stacked_layout) # Monaco widget - self.monaco_editor = MonacoWidget() + self.monaco_editor = MonacoWidget(parent=self) self._customize_monaco() self.stacked_layout.addWidget(self.monaco_editor) - self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + # Overlay widget + self._overlay_text = "Select a single device to view its config." + self._overlay_widget = QtWidgets.QLabel(text=self._overlay_text) self._customize_overlay() self.stacked_layout.addWidget(self._overlay_widget) self.stacked_layout.setCurrentWidget(self._overlay_widget) def _customize_monaco(self): - + """Customize the Monaco editor for YAML display.""" self.monaco_editor.set_language("yaml") self.monaco_editor.set_vim_mode_enabled(False) self.monaco_editor.set_minimap_enabled(False) - # self.monaco_editor.setFixedHeight(600) self.monaco_editor.set_readonly(True) self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) self.monaco_editor.editor.set_line_numbers_mode("off") def _customize_overlay(self): + """Customize the overlay widget.""" self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._overlay_widget.setAutoFillBackground(True) self._overlay_widget.setSizePolicy( @@ -52,13 +57,24 @@ def _customize_overlay(self): @SafeSlot(dict) def on_select_config(self, device: list[dict]): - """Handle selection of a device from the device table.""" + """ + Handle selection of a device from the device table. If more than one device is selected, + show an overlay message. Otherwise, display the device config in YAML format. + + Args: + device (list[dict]): The selected device configuration. + """ if len(device) != 1: text = "" self.stacked_layout.setCurrentWidget(self._overlay_widget) else: try: - text = yaml.dump(device[0], default_flow_style=False) + # Cast set to list to ensure proper YAML dumping + cfg = device[0] + for k, v in cfg.items(): + if isinstance(v, set): + cfg[k] = list(v) + text = yaml.dump(cfg, default_flow_style=False) self.stacked_layout.setCurrentWidget(self.monaco_editor) except Exception: content = traceback.format_exc() @@ -71,12 +87,14 @@ def on_select_config(self, device: list[dict]): self.monaco_editor.set_readonly(True) # Disable editing again -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys + from bec_qthemes import apply_theme from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + apply_theme("dark") widget = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(widget) widget.setLayout(layout) @@ -86,13 +104,14 @@ def on_select_config(self, device: list[dict]): layout.addWidget(config_view) combo_box = QtWidgets.QComboBox() config = config_view.client.device_manager._get_redis_device_config() - combo_box.addItems([""] + [str(v) for v, item in enumerate(config)]) + combo_box.addItems([""] + [f"{v} : {item.get('name', '')}" for v, item in enumerate(config)]) def on_select(text): if text == "": config_view.on_select_config([]) else: - config_view.on_select_config([config[int(text)]]) + index = int(text.split(" : ")[0]) + config_view.on_select_config([config[index]]) combo_box.currentTextChanged.connect(on_select) layout.addWidget(combo_box) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index 553462a00..cb990fd68 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -5,11 +5,9 @@ import inspect import re import textwrap -import traceback from bec_lib.logger import bec_logger -from bec_lib.plugin_helper import get_plugin_class, plugin_package_name -from bec_lib.utils.rpc_utils import rgetattr +from bec_lib.plugin_helper import get_plugin_class from qtpy import QtCore, QtWidgets from bec_widgets.utils.error_popups import SafeSlot @@ -86,7 +84,8 @@ def on_select_config(self, device: list[dict]): if len(device) != 1: self._set_text("") return - device_class = device[0].get("deviceClass", "") + device_name = list(device[0].keys())[0] + device_class = device[0][device_name].get("deviceClass", "") self.set_device_class(device_class) @SafeSlot(str) @@ -102,7 +101,7 @@ def set_device_class(self, device_class_str: str) -> None: self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys from qtpy.QtWidgets import QApplication diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py deleted file mode 100644 index a73ada115..000000000 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Module to run a static tests for devices from a yaml config.""" - -from __future__ import annotations - -import enum -import re -from collections import deque -from concurrent.futures import CancelledError, Future, ThreadPoolExecutor -from html import escape -from threading import Event, RLock -from typing import Any, Iterable - -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy import QtCore, QtWidgets - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget - -READY_TO_TEST = False - -logger = bec_logger.logger - -try: - import bec_server - import ophyd_devices - - READY_TO_TEST = True -except ImportError: - logger.warning(f"Optional dependencies not available: {ImportError}") - ophyd_devices = None - bec_server = None - -try: - from ophyd_devices.utils.static_device_test import StaticDeviceTest -except ImportError: - StaticDeviceTest = None - - -class ValidationStatus(int, enum.Enum): - """Validation status for device configurations.""" - - PENDING = 0 # colors.default - VALID = 1 # colors.highlight - FAILED = 2 # colors.emergency - - -class DeviceValidationResult(QtCore.QObject): - """Simple object to inject validation signals into QRunnable.""" - - # Device validation signal, device_name, ValidationStatus as int, error message or '' - device_validated = QtCore.Signal(str, bool, str) - - -class DeviceTester(QtCore.QRunnable): - def __init__(self, config: dict) -> None: - super().__init__() - self.signals = DeviceValidationResult() - self.shutdown_event = Event() - - self._config = config - - self._max_threads = 4 - self._pending_event = Event() - self._lock = RLock() - self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester") - - self._pending_queue: deque[tuple[str, dict]] = deque([]) - self._active: set[str] = set() - - QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set()) - - def run(self): - if StaticDeviceTest is None: - logger.error("Ophyd devices or bec_server not available, cannot run validation.") - return - while not self.shutdown_event.is_set(): - self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s - if len(self._active) >= self._max_threads: - self._pending_event.clear() # it will be set again on removing something from active - continue - with self._lock: - if len(self._pending_queue) > 0: - item, cfg, connect = self._pending_queue.pop() - self._active.add(item) - fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect) - fut.__dict__["__device_name"] = item - fut.add_done_callback(self._done_cb) - self._safe_check_and_clear() - self._cleanup() - - def submit(self, devices: Iterable[tuple[str, dict, bool]]): - with self._lock: - self._pending_queue.extend(devices) - self._pending_event.set() - - @staticmethod - def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]: - tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None - results = tester.run_with_list_output(connect=connect) - return name, results[0].success, results[0].message - - def _safe_check_and_clear(self): - with self._lock: - if len(self._pending_queue) == 0: - self._pending_event.clear() - - def _safe_remove_from_active(self, name: str): - with self._lock: - self._active.remove(name) - self._pending_event.set() # check again once a completed task is removed - - def _done_cb(self, future: Future): - try: - name, success, message = future.result() - except CancelledError: - return - except Exception as e: - name, success, message = future.__dict__["__device_name"], False, str(e) - finally: - self._safe_remove_from_active(future.__dict__["__device_name"]) - self.signals.device_validated.emit(name, success, message) - - def _cleanup(self): ... - - -class ValidationListItem(QtWidgets.QWidget): - """Custom list item widget showing device name and validation status.""" - - def __init__(self, device_name: str, device_config: dict, parent=None): - """ - Initialize the validation list item. - - Args: - device_name (str): The name of the device. - device_config (dict): The configuration of the device. - validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. - parent (QtWidgets.QWidget, optional): The parent widget. - """ - super().__init__(parent) - self.main_layout = QtWidgets.QHBoxLayout(self) - self.main_layout.setContentsMargins(2, 2, 2, 2) - self.main_layout.setSpacing(4) - self.device_name = device_name - self.device_config = device_config - self.validation_msg = "Validation in progress..." - self._setup_ui() - - def _setup_ui(self): - """Setup the UI for the list item.""" - label = QtWidgets.QLabel(self.device_name) - self.main_layout.addWidget(label) - self.main_layout.addStretch() - self._spinner = SpinnerWidget(parent=self) - self._spinner.speed = 80 - self._spinner.setFixedSize(24, 24) - self.main_layout.addWidget(self._spinner) - self._base_style = "font-weight: bold;" - self.setStyleSheet(self._base_style) - self._start_spinner() - - def _start_spinner(self): - """Start the spinner animation.""" - self._spinner.start() - - def _stop_spinner(self): - """Stop the spinner animation.""" - self._spinner.stop() - self._spinner.setVisible(False) - - @SafeSlot() - def on_validation_restart(self): - """Handle validation restart.""" - self.validation_msg = "" - self._start_spinner() - self.setStyleSheet("") # Check if this works as expected - - @SafeSlot(str) - def on_validation_failed(self, error_msg: str): - """Handle validation failure.""" - self.validation_msg = error_msg - colors = get_accent_colors() - self._stop_spinner() - self.main_layout.removeWidget(self._spinner) - self._spinner.deleteLater() - label = QtWidgets.QLabel("") - icon = material_icon("error", color=colors.emergency, size=(24, 24)) - label.setPixmap(icon) - self.main_layout.addWidget(label) - - -class DMOphydTest(BECWidget, QtWidgets.QWidget): - """Widget to test device configurations using ophyd devices.""" - - # Signal to emit the validation status of a device - device_validated = QtCore.Signal(str, int) - # validation_msg in markdown format - validation_msg_md = QtCore.Signal(str) - - def __init__(self, parent=None, client=None): - super().__init__(parent=parent, client=client) - if not READY_TO_TEST: - self.setDisabled(True) - self.tester = None - else: - self.tester = DeviceTester({}) - self.tester.signals.device_validated.connect(self._on_device_validated) - QtCore.QThreadPool.globalInstance().start(self.tester) - self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} - # TODO Consider using the thread pool from BECConnector instead of fetching the global instance! - self._thread_pool = QtCore.QThreadPool.globalInstance() - - self._main_layout = QtWidgets.QVBoxLayout(self) - self._main_layout.setContentsMargins(0, 0, 0, 0) - self._main_layout.setSpacing(0) - - # We add a splitter between the list and the text box - self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) - self._main_layout.addWidget(self.splitter) - - self._setup_list_ui() - - def _setup_list_ui(self): - """Setup the list UI.""" - self._list_widget = QtWidgets.QListWidget(self) - self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.splitter.addWidget(self._list_widget) - # Connect signals - self._list_widget.currentItemChanged.connect(self._on_current_item_changed) - - @SafeSlot(list, bool) - @SafeSlot(list, bool, bool) - def change_device_configs( - self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False - ) -> None: - """Receive an update with device configs. - - Args: - device_configs (list[dict[str, Any]]): The updated device configurations. - """ - for cfg in device_configs: - name = cfg.get("name", "") - if added: - if name in self._device_list_items: - continue - if self.tester: - self._add_device(name, cfg) - self.tester.submit([(name, cfg, connect)]) - continue - if name not in self._device_list_items: - continue - self._remove_list_item(name) - - def _add_device(self, name, cfg): - item = QtWidgets.QListWidgetItem(self._list_widget) - widget = ValidationListItem(device_name=name, device_config=cfg) - - # wrap it in a QListWidgetItem - item.setSizeHint(widget.sizeHint()) - self._list_widget.addItem(item) - self._list_widget.setItemWidget(item, widget) - self._device_list_items[name] = item - - def _remove_list_item(self, device_name: str): - """Remove a device from the list.""" - # Get the list item - item = self._device_list_items.pop(device_name) - - # Retrieve the custom widget attached to the item - widget = self._list_widget.itemWidget(item) - if widget is not None: - widget.deleteLater() # clean up custom widget - - # Remove the item from the QListWidget - row = self._list_widget.row(item) - self._list_widget.takeItem(row) - - @SafeSlot(str, bool, str) - def _on_device_validated(self, device_name: str, success: bool, message: str): - """Handle the device validation result. - - Args: - device_name (str): The name of the device. - success (bool): Whether the validation was successful. - message (str): The validation message. - """ - logger.info(f"Device {device_name} validation result: {success}, message: {message}") - item = self._device_list_items.get(device_name, None) - if not item: - logger.error(f"Device {device_name} not found in the list.") - return - if success: - self._remove_list_item(device_name=device_name) - self.device_validated.emit(device_name, ValidationStatus.VALID.value) - else: - widget: ValidationListItem = self._list_widget.itemWidget(item) - widget.on_validation_failed(message) - self.device_validated.emit(device_name, ValidationStatus.FAILED.value) - - def _on_current_item_changed( - self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem - ): - """Handle the current item change in the list widget. - - Args: - current (QListWidgetItem): The currently selected item. - previous (QListWidgetItem): The previously selected item. - """ - widget: ValidationListItem = self._list_widget.itemWidget(current) - if widget: - try: - formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg) - self.validation_msg_md.emit(formatted_md) - except Exception as e: - logger.error( - f"##Error formatting validation message for device {widget.device_name}:\n{e}" - ) - self.validation_msg_md.emit(widget.validation_msg) - else: - self.validation_msg_md.emit("") - - def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: - """ - Simple HTML formatting for validation messages, wrapping text naturally. - - Args: - device_name (str): The name of the device. - raw_msg (str): The raw validation message. - """ - if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": - return f"### Validation in progress for {device_name}... \n\n" - - # Regex to capture repeated ERROR patterns - pat = re.compile( - r"ERROR:\s*(?P[^\s]+)\s+" - r"(?Pis not valid|is not connectable|failed):\s*" - r"(?P.*?)(?=ERROR:|$)", - re.DOTALL, - ) - blocks = [] - for m in pat.finditer(raw_msg): - dev = m.group("device") - status = m.group("status") - detail = m.group("detail").strip() - lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] - blocks.append("\n\n".join(lines)) - - # Fallback: If no patterns matched, return the raw message - if not blocks: - return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" - - return "\n\n---\n\n".join(blocks) - - def validation_running(self): - return self._device_list_items != {} - - @SafeSlot() - def clear_list(self): - """Clear the device list.""" - self._thread_pool.clear() - if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish - logger.error("Failed to wait for threads to finish. Removing items from the list.") - self._device_list_items.clear() - self._list_widget.clear() - self.validation_msg_md.emit("") - - def remove_device(self, device_name: str): - """Remove a device from the list.""" - item = self._device_list_items.pop(device_name, None) - if item: - self._list_widget.removeItemWidget(item) - - def cleanup(self): - if self.tester: - self.tester.shutdown_event.set() - return super().cleanup() - - -if __name__ == "__main__": - import sys - - from bec_lib.bec_yaml_loader import yaml_load - - # pylint: disable=ungrouped-imports - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - wid = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(wid) - wid.setLayout(layout) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - device_manager_ophyd_test = DMOphydTest() - try: - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" - config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] - except Exception as e: - logger.error(f"Error loading config: {e}") - import os - - import bec_lib - - config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml") - config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] - - config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) - device_manager_ophyd_test.change_device_configs(config, True, True) - layout.addWidget(device_manager_ophyd_test) - device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") - device_manager_ophyd_test.resize(800, 600) - text_box = QtWidgets.QTextEdit() - text_box.setReadOnly(True) - layout.addWidget(text_box) - device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown) - wid.show() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py new file mode 100644 index 000000000..829937707 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/__init__.py @@ -0,0 +1,8 @@ +from .ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from .validation_list_item import ValidationButton, ValidationListItem diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py new file mode 100644 index 000000000..9838720d7 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -0,0 +1,825 @@ +""" +Module with a test widget that allows to run the ophyd_devices static tests +utilities for a device config test. Results are displayed in two lists (running, completed). +In addition, it allows to configure the test parameters. + +-> Connect: Try to establish a connection to the device +-> Timeout: Timeout for connection attempt. Default here is 5s. +-> Force Connect: To force connection even if already connected. + Mostly relevant for ADBase integrations. +""" + +import queue +import weakref +from typing import Any +from uuid import uuid4 + +from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_list import BECList +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + ValidationButton, + ValidationListItem, + format_error_to_md, + get_validation_icons, +) + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server # type: ignore + import ophyd_devices # type: ignore + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class DeviceTestResult(QtCore.QObject): + """Simple object to inject device validation signal to DeviceTest QRunnable.""" + + # ValidationResult: device_config, config_status, connection_status, error_message + device_validated = QtCore.Signal(dict, int, int, str) + device_validation_started = QtCore.Signal(str) + + +class DeviceTest(QtCore.QRunnable): + """QRunnable to run a device test in the QT thread pool.""" + + def __init__( + self, + device_model: DeviceTestModel, + enable_connect: bool, + force_connect: bool, + timeout: float, + ): + super().__init__() + self.uuid = device_model.uuid + test_config = {device_model.device_name: device_model.device_config} + self.tester = StaticDeviceTest(config_dict=test_config) + self.signals = DeviceTestResult() + self.device_config = device_model.device_config + self.enable_connect = enable_connect + self.force_connect = force_connect + self.timeout = timeout + self._cancelled = False + + def cancel(self): + """Cancel the device test.""" + self._cancelled = True + + def run(self): + """Run the device test.""" + if not READY_TO_TEST: + logger.error("Cannot run device test: dependencies not available.") + return + device_name = self.device_config.get("name", "") + self.signals.device_validation_started.emit(device_name) # Emit started signal + if self._cancelled: + logger.debug("Device test cancelled before start.") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{self.device_config.get('name')} was cancelled by user.", + ) + return + results = self.tester.run_with_list_output( + connect=self.enable_connect, + force_connect=self.force_connect, + timeout_per_device=self.timeout, + ) + if not results: + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "Results from OphydDevices StaticDeviceTest are empty.", + ) + return + try: + config_is_valid = int(results[0].config_is_valid) + connection_status = ( + int(results[0].success) if self.enable_connect else ConnectionStatus.UNKNOWN.value + ) + error_message = results[0].message or "" + self.signals.device_validated.emit( + self.device_config, config_is_valid, connection_status, error_message + ) + except Exception as e: + logger.error(f"Error reading results from device test: {e}") + self.signals.device_validated.emit( + self.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"Error processing device test results: {e}", + ) + + +class ThreadPoolManager(QtCore.QObject): + """ + Manager wrapping QThreadPool to expose a queue for jobs. + It allows queued jobs to be cancelled if they have not yet started. + + Args: + max_workers (int): Maximum number of concurrent workers. + poll_interval_ms (int): Poll interval in milliseconds to check for new jobs. + """ + + validations_are_running = QtCore.Signal(bool) + device_validation_started = QtCore.Signal(str) + device_validated = QtCore.Signal(dict, int, int, str) + + def __init__(self, parent=None, max_workers: int = 4, poll_interval_ms: int = 100): + super().__init__(parent=parent) + self.pool = QtCore.QThreadPool(parent=parent) + self.pool.setMaxThreadCount(max_workers) + + self._queue = queue.Queue() + self._timer = QtCore.QTimer(parent=parent) + self._timer.timeout.connect(self._process_queue) + self.poll_interval_ms = poll_interval_ms + self._timer.setInterval(self.poll_interval_ms) + self._active_tests: dict[str, weakref.ReferenceType[DeviceTest]] = {} + + def start_polling(self): + """Start the polling timer.""" + if not self._timer.isActive(): + self._timer.start() + + def stop_polling(self): + """Stop the polling timer.""" + if self._timer.isActive(): + self._timer.stop() + + def _emit_device_validation_started(self, device_name: str): + """Emit device validation started signal.""" + self.device_validation_started.emit(device_name) + + def _emit_device_validated( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Emit device validated signal.""" + self.device_validated.emit(device_config, config_status, connection_status, error_message) + + def submit(self, device_name: str, device_test: DeviceTest): + """Queue a job for execution.""" + device_test.signals.device_validation_started.connect(self._emit_device_validation_started) + device_test.signals.device_validated.connect(self._emit_device_validated) + self._queue.put((device_name, device_test)) + + def clear_device_in_queue(self, device_name: str): + """Remove a specific device test from the queue.""" + if device_name in self._active_tests: + try: + ref = self._active_tests.pop(device_name) + obj = ref() + if obj and hasattr(obj, "cancel"): + obj.cancel() + obj.signals.device_validated.disconnect() + except KeyError: + logger.debug(f"Device {device_name} not found in active tests during cancellation.") + return + + with self._queue.mutex: + for name, runnable in self._queue.queue: + if name == device_name: # found the device to remove, discard it + runnable.cancel() + runnable.signals.device_validated.disconnect() + self._queue.queue = queue.deque( + item for item in self._queue.queue if item[0] != device_name + ) + break + + def clear_queue(self): + """Remove all queued (not yet started) jobs.""" + running = self.get_active_tests() + scheduled = self.get_scheduled_tests() + for device_name in running + scheduled: + self.clear_device_in_queue(device_name) + + def get_active_tests(self) -> list[str]: + """Return a list of currently active test device names.""" + return list(self._active_tests.keys()) + + def get_scheduled_tests(self) -> list[str]: + """Return a list of currently scheduled (queued) test device names.""" + with self._queue.mutex: + return [device_name for device_name, _ in list(self._queue.queue)] + + def _process_queue(self): + """Start new jobs if there is capacity. Runs with specified poll interval.""" + while not self._queue.empty() and len(self._active_tests) < self.pool.maxThreadCount(): + device_name, runnable = self._queue.get() + runnable.signals.device_validated.connect(self._on_task_finished) + self._active_tests[device_name] = weakref.ref(runnable) + self.pool.start(runnable) + self.validations_are_running.emit(len(self._active_tests) > 0) + + @SafeSlot(dict, int, int, str) + def _on_task_finished( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ): + """Handle task finished signal to update active thread count.""" + device_name = device_config.get("name", None) + if device_name: + self._active_tests.pop(device_name, None) + + +class LegendLabel(QtWidgets.QWidget): + """Wrapper widget for legend labels with icon and text for OphydValidation.""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(18, 18), convert_to_pixmap=False + ) + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(4, 0, 4, 0) + layout.setSpacing(8) + + # Config Status Legend + config_legend = QtWidgets.QLabel("Config Legend:") + layout.addWidget(config_legend, 0, 0) + for ii, status in enumerate( + [ConfigStatus.UNKNOWN, ConfigStatus.INVALID, ConfigStatus.VALID] + ): + icon = self._icons["config_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(False) + icon_widget.set_enabled_style(False) + icon_widget.setToolTip(f"Device Configuration: {status.description()}") + layout.addWidget(icon_widget, 0, ii + 1) + + # Connection Status Legend + connection_status_legend = QtWidgets.QLabel("Connect Legend:") + layout.addWidget(connection_status_legend, 1, 0) + for ii, status in enumerate( + [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ConnectionStatus.CAN_CONNECT, + ConnectionStatus.CONNECTED, + ] + ): + icon = self._icons["connection_status"][status] + icon_widget = ValidationButton(parent=self, icon=icon) + icon_widget.setEnabled(False) + icon_widget.set_enabled_style(False) + icon_widget.setToolTip(f"Connection Status: {status.description()}") + layout.addWidget(icon_widget, 1, ii + 1) + layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column + + +class OphydValidation(BECWidget, QtWidgets.QWidget): + """ + Widget to manage and run ophyd device tests. + + Args: + parent (QWidget, optional): Parent widget. Defaults to None. + client (BECClient, optional): BEC client instance. Defaults to None. + hide_legend (bool, optional): Whether to hide the legend. Defaults to False. + """ + + RPC = False + + # ValidationResult: device_config, config_status, connection_status, error_message + validation_completed = QtCore.Signal(dict, int, int, str) + # ValidationResult: device_name, config_status, connection_status, error_message, formatted_error_message + item_clicked = QtCore.Signal(str, int, int, str, str) + # Signal to indicate if validations are currently running + validations_are_running = QtCore.Signal(bool) + # Signal to emit list of ValidationResults (device_config, config_status, connection_status, error_message) at once + multiple_validations_completed = QtCore.Signal(list) + + def __init__(self, parent=None, client=None, hide_legend: bool = False): + super().__init__(parent=parent, client=client, theme_update=True) + self._running_ophyd_tests = False + if not READY_TO_TEST: + self.setDisabled(True) + self.thread_pool_manager = None + else: + self.thread_pool_manager = ThreadPoolManager(parent=self, max_workers=4) + self.thread_pool_manager.validations_are_running.connect(self._set_running_ophyd_tests) + self.thread_pool_manager.device_validated.connect(self._on_device_test_completed) + self.thread_pool_manager.device_validation_started.connect( + self._trigger_validation_started + ) + + self._validation_icons = get_validation_icons( + colors=get_accent_colors(), icon_size=(32, 32), convert_to_pixmap=False + ) + + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(4) + self._colors = get_accent_colors() + + # Setup main UI + self.list_widget = self._create_list_widget_with_label("Running & Failed Validations") + if not hide_legend: + legend_widget = LegendLabel(parent=self) + self._main_layout.addWidget(legend_widget) + self._thread_pool_poll_loop() + + def apply_theme(self, theme: str): + """Apply the current theme to the widget.""" + self._colors = get_accent_colors() + # TODO consider removing as accent colors are the same across themes, or am I wrong? + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + + def _thread_pool_poll_loop(self): + """Start the thread pool polling loop.""" + if self.thread_pool_manager: + self.thread_pool_manager.start_polling() + + def _create_list_widget_with_label(self, label_text: str) -> BECList: + """Setup the running validations section.""" + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Section title + title_layout = QtWidgets.QHBoxLayout() + title_layout.setContentsMargins(0, 0, 0, 0) + title_label = QtWidgets.QLabel(label_text) + title_label.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + status_label = QtWidgets.QLabel("Config | Connect") + status_label.setStyleSheet("font-weight: bold; font-size: 9px; padding: 2px;") + title_layout.addWidget(title_label) + title_layout.addStretch(1) + title_layout.addWidget(status_label) + layout.addLayout(title_layout) + + # Separator line + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + layout.addWidget(separator) + + # List widget for running validations + list_w = BECList(parent=self) + list_w.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + list_w.itemClicked.connect(self._on_item_clicked) + list_w.currentItemChanged.connect(self._on_current_item_changed) + layout.addWidget(list_w) + + # Stop Running validation button + self._stop_validation_button = QtWidgets.QPushButton("Stop Running Validations") + self._stop_validation_button.clicked.connect(self.cancel_all_validations) + self._stop_validation_button.setStyleSheet( + f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;" + ) + self._stop_validation_button.setVisible(False) + layout.addWidget(self._stop_validation_button) + self.validations_are_running.connect(self._stop_validation_button.setVisible) + self._main_layout.addWidget(widget) + + return list_w + + ########################## + ### Event Handlers + ########################## + + @SafeSlot(bool) + def _set_running_ophyd_tests(self, running: bool): + """Set the running ophyd tests state.""" + self.running_ophyd_tests = running + + @SafeSlot(QtWidgets.QListWidgetItem, QtWidgets.QListWidgetItem) + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle current item changed.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(current) + if widget: + self._emit_item_clicked(widget) + + @SafeSlot(QtWidgets.QListWidgetItem) + def _on_item_clicked(self, item: QtWidgets.QListWidgetItem): + """Handle click on running item.""" + widget: ValidationListItem = self.list_widget.get_widget_for_item(item) + if widget: + self._emit_item_clicked(widget) + + def _emit_item_clicked(self, widget: ValidationListItem): + format_error_msg = format_error_to_md( + widget.device_model.device_name, widget.device_model.validation_msg + ) + self.item_clicked.emit( + widget.device_model.device_name, + widget.device_model.config_status, + widget.device_model.connection_status, + widget.device_model.validation_msg, + format_error_msg, + ) + + ########################### + ### Properties + ########################### + + @SafeProperty(bool, notify=validations_are_running) + # pylint: disable=method-hidden + def running_ophyd_tests(self) -> bool: + """Indicates if validations are currently running.""" + return self._running_ophyd_tests + + @running_ophyd_tests.setter + def running_ophyd_tests(self, value: bool) -> None: + if self._running_ophyd_tests != value: + self._running_ophyd_tests = value + self.validations_are_running.emit(value) + + ########################### + ### Public Methods + ########################### + + @SafeSlot() + def clear_all(self): + """Clear all running and failed validations.""" + self.thread_pool_manager.clear_queue() + self.list_widget.clear_widgets() + + def get_device_configs(self) -> list[dict[str, Any]]: + """ + Get the current device configurations being tested. + + Returns: + list[dict[str, Any]]: List of device configurations. + """ + widgets: list[ValidationListItem] = self.list_widget.get_widgets() + return [widget.device_model.device_config for widget in widgets] + + @SafeSlot(list, bool) + @SafeSlot(list, bool, bool) + @SafeSlot(list, bool, bool, bool, float) + def change_device_configs( + self, + device_configs: list[dict[str, Any]], + added: bool, + connect: bool = False, + force_connect: bool = False, + timeout: float = 5.0, + ) -> None: + """ + Change the device configuration to test. If added is False, existing devices are removed. + Device tests will be removed based on device names. No duplicates are allowed. + + Args: + device_configs (list[dict[str, Any]]): List of device configurations. + added (bool): Whether the devices are added to the existing list. + connect (bool, optional): Whether to attempt connection during validation. Defaults to False. + force_connect (bool, optional): Whether to force connection during validation. Defaults to False. + timeout (float, optional): Timeout for connection attempt. Defaults to 5.0. + """ + if not READY_TO_TEST: + logger.error("Cannot change device configs: dependencies not available.") + return + # Track all devices that are already in the running session from the + # config updates to avoid sending multiple single device validation signals. + # Sending successive single updates may affect the UI performance on the receiving end. + devices_already_in_session = [] + for cfg in device_configs: + device_name = cfg.get("name", None) + if device_name is None: # Config missing name, will be skipped.. + logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.") + continue + if not added: # Remove requested + self._remove_device_config(cfg) + continue + if self._is_device_in_redis_session(cfg.get("name"), cfg): + logger.debug( + f"Device {device_name} already in running session with same config. Skipping." + ) + devices_already_in_session.append( + ( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) + ) + self._remove_device_config(cfg) + continue + if not self._device_already_exists(cfg.get("name")): # New device case + self._add_device_config( + cfg, connect=connect, force_connect=force_connect, timeout=timeout + ) + else: # Update existing, but removing first + logger.info(f"Device {cfg.get('name')} already exists, re-adding it.") + self._remove_device_config(cfg) + self._add_device_config( + cfg, connect=connect, force_connect=force_connect, timeout=timeout + ) + # Send out batch of updates for devices already in session + if devices_already_in_session: + self.multiple_validations_completed.emit(devices_already_in_session) + + def cancel_validation(self, device_name: str) -> None: + """Cancel a running validation for a specific device. + + Args: + device_name (str): Name of the device to cancel validation for. + """ + if not READY_TO_TEST: + logger.error("Cannot cancel validation: dependencies not available.") + return + if self.thread_pool_manager: + self.thread_pool_manager.clear_device_in_queue(device_name) + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + self._on_device_test_completed( + widget.device_model.device_config, + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + f"{widget.device_model.device_name} was cancelled by user.", + ) + + def cancel_all_validations(self) -> None: + """Cancel all running validations.""" + if not READY_TO_TEST: + logger.error("Cannot cancel validations: dependencies not available.") + return + running = self.thread_pool_manager.get_active_tests() + scheduled = self.thread_pool_manager.get_scheduled_tests() + for device_name in running + scheduled: + self.cancel_validation(device_name) + + ################# + ### Private methods + ################# + + def _device_already_exists(self, device_name: str) -> bool: + return device_name in self.list_widget + + def _add_device_config( + self, device_config: dict[str, Any], connect: bool, force_connect: bool, timeout: float + ) -> None: + device_name = device_config.get("name") + # Check if device is in redis session with same config, if yes don't even bother testing.. + device_test_model = DeviceTestModel( + uuid=f"device_test_{device_name}_uuid_{uuid4()}", + device_name=device_name, + device_config=device_config, + ) + + widget = ValidationListItem( + parent=self, device_model=device_test_model, validation_icons=self._validation_icons + ) + widget.request_rerun_validation.connect(self._on_request_rerun_validation) + self.list_widget.add_widget_item(device_name, widget) + self.__delayed_submit_test(widget, connect, force_connect, timeout) + + def _remove_device_config(self, device_config: dict[str, Any]) -> None: + device_name = device_config.get("name") + if not device_name: + logger.error(f"Device config missing 'name': {device_config}. Cannot remove device.") + return + if not self._device_already_exists(device_name): + logger.debug( + f"Device with name {device_name} not found in OphydValidation, can't remove it." + ) + return + if self.thread_pool_manager: + self.thread_pool_manager.clear_device_in_queue(device_name) + self.list_widget.remove_widget_item(device_name) + + @SafeSlot(str, dict, bool, bool, float) + def _on_request_rerun_validation( + self, + device_name: str, + device_config: dict[str, Any], + connect: bool, + force_connect: bool, + timeout: float, + ) -> None: + """Handle request to re-run validation for a device.""" + if not self._device_already_exists(device_name): + logger.debug( + f"Device with name {device_name} not found in OphydValidation, can't re-run." + ) + return + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget and not widget.is_running: + self.__delayed_submit_test(widget, connect, force_connect, timeout) + else: + logger.debug(f"Device {device_name} is already running validation, cannot re-run.") + + def _emit_device_in_redis_session(self, device_config: dict) -> None: + self.validation_completed.emit( + device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{device_config.get('name')} is OK. Already loaded in running session.", + ) + + def __delayed_submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Delayed submission of device test to ensure UI updates.""" + QtCore.QTimer.singleShot( + 0, lambda: self._submit_test(widget, connect, force_connect, timeout) + ) + + def _submit_test( + self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float + ) -> None: + """Submit a device test to the thread pool.""" + if not READY_TO_TEST or StaticDeviceTest is None: + logger.error("Cannot submit device test: dependencies not available.") + return + # Check if device is already in redis session with same config + if self._is_device_in_redis_session( + widget.device_model.device_name, widget.device_model.device_config + ): + logger.info( + f"Device {widget.device_model.device_name} already in running session with same config. " + "Skipping validation." + ) + self.validation_completed.emit( + widget.device_model.device_config, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + f"{widget.device_model.device_name} is OK. Already loaded in running session.", + ) + # Remove widget from list as it's safe to assume it can be loaded. + self._remove_device_config(widget.device_model.device_config) + return + runnable = DeviceTest( + device_model=widget.device_model, + enable_connect=connect, + force_connect=force_connect, + timeout=timeout, + ) + widget.validation_scheduled() + if self.thread_pool_manager: + self.thread_pool_manager.submit(widget.device_model.device_name, runnable) + + def _trigger_validation_started(self, device_name: str) -> None: + """Trigger validation started for a specific device.""" + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + widget.validation_started() + + def _on_device_test_completed( + self, device_config: dict, config_status: int, connection_status: int, error_message: str + ) -> None: + """Handle device test completion.""" + device_name = device_config.get("name") + if not self._device_already_exists(device_name): + logger.debug(f"Received test result for unknown device {device_name}. Ignoring.") + return + if config_status == ConfigStatus.VALID.value and connection_status in [ + ConnectionStatus.CONNECTED.value, + ConnectionStatus.CAN_CONNECT.value, + ]: + # Validated successfully, remove item from running list + self.list_widget.remove_widget_item(device_name) + self.validation_completed.emit( + device_config, config_status, connection_status, error_message + ) + return + widget = self.list_widget.get_widget(device_name) + if widget: + widget.on_validation_finished( + validation_msg=error_message, + config_status=config_status, + connection_status=connection_status, + ) + self.validation_completed.emit( + device_config, config_status, connection_status, error_message + ) + + def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool: + """Check if a device is in the running section.""" + dev_obj = self.client.device_manager.devices.get(device_name, None) + if dev_obj is None or dev_obj.enabled is False: + return False + return self._compare_device_configs(dev_obj._config, device_config) + + def _compare_device_configs(self, config1: dict, config2: dict) -> bool: + """Compare two device configurations through the Device model in bec_lib.atlas_models. + + Args: + config1 (dict): The first device configuration. + config2 (dict): The second device configuration. + + Returns: + bool: True if the configurations are equivalent, False otherwise. + """ + try: + model1 = DeviceModel.model_validate(config1) + model2 = DeviceModel.model_validate(config2) + return model1 == model2 + except Exception: + return False + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QtWidgets.QApplication(sys.argv) + import os + import random + + import bec_lib + from bec_lib.bec_yaml_loader import yaml_load + from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path + from bec_qthemes import apply_theme + + apply_theme("light") + # Main widget + wid = QtWidgets.QWidget() + w_layout = QtWidgets.QVBoxLayout(wid) + w_layout.setContentsMargins(0, 0, 0, 0) + w_layout.setSpacing(0) + wid.setLayout(w_layout) + # Check if plugin is installed + + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + cfgs = [""] + cfgs.extend([os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")]) + if plugin_path: + print(f"Adding configs from plugin {plugin_name} at {plugin_path}") + cfg_base_path = os.path.join(plugin_path, plugin_name, "device_configs") + config_files = os.listdir(cfg_base_path) + cfgs.extend( + [os.path.join(cfg_base_path, f) for f in config_files if f.endswith((".yaml", ".yml"))] + ) + + combo_box_configs = QtWidgets.QComboBox() + combo_box_configs.addItems(cfgs) + combo_box_configs.setCurrentIndex(0) + + but_layout = QtWidgets.QHBoxLayout() + but_layout.addWidget(combo_box_configs) + button_reset = QtWidgets.QPushButton("Clear All") + but_layout.addWidget(button_reset) + button_clear_random = QtWidgets.QPushButton("Clear random amount") + but_layout.addWidget(button_clear_random) + w_layout.addLayout(but_layout) + + def _load_config(config_path: str): + current_config = device_manager_ophyd_test.get_device_configs() + device_manager_ophyd_test.change_device_configs(current_config, False) + if not config_path: # empty escape + return + try: + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, False, False, 2.0) + except Exception as e: + logger.error(f"Error loading config {config_path}: {e}") + + def _clear_random_entries(): + current_config = device_manager_ophyd_test.get_device_configs() + n_remove = random.randint(1, len(current_config)) + to_remove = random.sample(current_config, n_remove) + device_manager_ophyd_test.change_device_configs(to_remove, False) + + device_manager_ophyd_test = OphydValidation() + button_reset.clicked.connect(device_manager_ophyd_test.clear_all) + combo_box_configs.currentTextChanged.connect(_load_config) + button_clear_random.clicked.connect(_clear_random_entries) + + w_layout.addWidget(device_manager_ophyd_test) + + # Add text box for results + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + w_layout.addWidget(text_box) + + def _validation_callback( + device_name: str, + config_status: int, + connection_status: int, + error_message: str, + formatted_error_message: str, + ): # type: ignore + text_box.setMarkdown(formatted_error_message) + + device_manager_ophyd_test.item_clicked.connect(_validation_callback) + wid.resize(600, 1000) + wid.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py new file mode 100644 index 000000000..8c4cdf327 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation_utils.py @@ -0,0 +1,171 @@ +import re +from enum import IntEnum +from functools import partial +from typing import Any, Literal + +from bec_qthemes import material_icon +from pydantic import BaseModel, Field +from qtpy import QtGui + +from bec_widgets.utils.colors import AccentColors + + +def format_error_to_md(device_name: str, raw_msg: str) -> str: + """ + Method to format a raw validation method into markdown for display. + The recognized patterns are: + - "'DEVICE_NAME' is OK. DETAIL" + - "ERROR: 'DEVICE_NAME' is not valid: DETAIL" + - "ERROR: 'DEVICE_NAME' is not connectable: DETAIL" + - "ERROR: 'DEVICE_NAME' failed: DETAIL" + If no patterns matched, the raw message is returned as a code block. + + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + + Returns: + str: The formatted markdown message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" + + # Regex to catch OK pattern + ok_pat = re.compile(r"(?P\S+)\s+is\s+OK\.?(?:\s*(?P.*))?$", re.IGNORECASE) + ok_match = ok_pat.search(raw_msg) + if ok_match: + device = ok_match.group("device") + detail = ok_match.group("detail").strip(".").strip() + return f"## Validation Success for {device}\n```\n{detail}\n```" + + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, + ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) + + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" + + return "\n\n---\n\n".join(blocks) + + +############################ +### Status Enums +############################ + + +class ConfigStatus(IntEnum): + """Validation status for device config validity. This includes the deviceClass check.""" + + INVALID = 0 + VALID = 1 + UNKNOWN = 2 + + def description(self) -> str: + """Get a human-readable description of the config status. + + Returns: + str: The description of the config status. + """ + descriptions = { + ConfigStatus.INVALID: "Invalid Configuration", + ConfigStatus.VALID: "Valid Configuration", + ConfigStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class ConnectionStatus(IntEnum): + """Connection status for device connectivity.""" + + CANNOT_CONNECT = 0 + CAN_CONNECT = 1 + CONNECTED = 2 + UNKNOWN = 3 + + def description(self) -> str: + """Get a human-readable description of the connection status. + + Returns: + str: The description of the connection status. + """ + descriptions = { + ConnectionStatus.CANNOT_CONNECT: "Cannot Connect", + ConnectionStatus.CAN_CONNECT: "Can Connect", + ConnectionStatus.CONNECTED: "Connected and Loaded", + ConnectionStatus.UNKNOWN: "Unknown", + } + return descriptions.get(self, "Unknown") + + +class DeviceTestModel(BaseModel): + """Model to hold device test parameters and results.""" + + uuid: str + device_name: str + device_config: dict[str, Any] + config_status: int = Field( + default=ConfigStatus.UNKNOWN.value, + description="Validation status of the device configuration.", + ) + connection_status: int = Field( + default=ConnectionStatus.UNKNOWN.value, description="Connection status of the device." + ) + validation_msg: str = Field(default="", description="Message from the last validation attempt.") + + +def get_validation_icons( + colors: AccentColors, icon_size: tuple[int, int], convert_to_pixmap: bool = False +) -> dict[Literal["config_status", "connection_status"], dict[int, QtGui.QPixmap | QtGui.QIcon]]: + """Get icons for validation statuses for ConfigStatus and ConnectionStatus. + + Args: + colors (AccentColors): The accent colors to use for the icons. + icon_size (tuple[int, int]): The size of the icons. + convert_to_pixmap (bool, optional): Whether to convert icons to pixmaps. Defaults to False. + + Returns: + dict: A dictionary with icons for config and connection statuses. + """ + material_icon_partial = partial( + material_icon, size=icon_size, convert_to_pixmap=convert_to_pixmap + ) + icons = { + "config_status": { + ConfigStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConfigStatus.VALID.value: material_icon_partial( + icon_name="check_circle", color=colors.success + ), + ConfigStatus.INVALID.value: material_icon_partial( + icon_name="error", color=colors.emergency + ), + }, + "connection_status": { + ConnectionStatus.UNKNOWN.value: material_icon_partial( + icon_name="question_mark", color=colors.default + ), + ConnectionStatus.CANNOT_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.emergency + ), + ConnectionStatus.CAN_CONNECT.value: material_icon_partial( + icon_name="cable", color=colors.success + ), + ConnectionStatus.CONNECTED.value: material_icon_partial( + icon_name="cast_connected", color=colors.success + ), + }, + } + return icons diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py new file mode 100644 index 000000000..3fe846685 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py @@ -0,0 +1,391 @@ +"""Module with validation items and a validation button for device testing UI.""" + +from typing import Literal + +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + get_validation_icons, +) +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +logger = bec_logger.logger + + +class ValidationButton(QtWidgets.QPushButton): + """ + Validation button with flat style and disabled appearance. + + Args: + parent (QtWidgets.QWidget | None): Parent widget. + icon (QtGui.QIcon | None): Icon to display on the button. + """ + + def __init__( + self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None + ) -> None: + super().__init__(parent=parent) + self.transparent_style = "background-color: transparent; border: none;" + if icon: + self.setIcon(icon) + self.setFlat(True) + self.setEnabled(True) + + def setEnabled(self, enabled: bool) -> None: + self.set_enabled_style(enabled) + return super().setEnabled(enabled) + + def set_enabled_style(self, enabled: bool) -> None: + """Set the enabled state of the button with style update. + + Args: + enabled (bool): Whether the button should be enabled. + """ + if enabled: + self.setStyleSheet("") + else: + self.setStyleSheet(self.transparent_style) + + +class ValidationDialog(QtWidgets.QDialog): + """ + Dialog to confirm re-validation with optional parameters. Once accepted, + the settings timeout, connect and force_connect can be retrieved through .result(). + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. + timeout (float, optional): The timeout for the validation. + connect (bool, optional): Whether to attempt connection during validation. + force_connect (bool, optional): Whether to force connection during validation. + """ + + def __init__( + self, parent=None, timeout: float = 5.0, connect: bool = False, force_connect: bool = False + ): + super().__init__(parent) + + self._result: tuple[float, bool, bool] = (timeout, connect, force_connect) + # Setup Dialog UI + self.setWindowTitle("Run Validation") + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + # label + self.label = QtWidgets.QLabel( + "Do you want to re-run validation with the following options?" + ) + self.label.setWordWrap(True) + layout.addWidget(self.label) + + # Setup options (note timeout will be simplified to int) + option_layout = QtWidgets.QVBoxLayout() + option_layout.setSpacing(16) + option_layout.setContentsMargins(0, 0, 0, 0) + + # Timeout + timeout_layout = QtWidgets.QHBoxLayout() + label_timeout = QtWidgets.QLabel("Timeout(s):") + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setRange(1, 300) + self.timeout_spin.setValue(int(timeout)) + timeout_layout.addWidget(label_timeout) + timeout_layout.addWidget(self.timeout_spin) + + # Connect checkbox + self.connect_checkbox = QtWidgets.QCheckBox("Test Connection") + self.connect_checkbox.setChecked(connect) + + # Force Connect checkbox + self.force_connect_checkbox = QtWidgets.QCheckBox("Force Connect") + self.force_connect_checkbox.setChecked(force_connect) + if self.connect_checkbox.isChecked() is False: + self.force_connect_checkbox.setEnabled(False) + # Deactivated if connect is unchecked + self.connect_checkbox.stateChanged.connect(self.force_connect_checkbox.setEnabled) + + # Add widgets to layout + option_layout.addLayout(timeout_layout) + option_layout.addWidget(self.connect_checkbox) + option_layout.addWidget(self.force_connect_checkbox) + layout.addLayout(option_layout) + + # Dialog Buttons: equal size, stacked horizontally + self.button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + self.adjustSize() + + def accept(self): + """Process the dialog acceptance and store the result.""" + self._result = ( + float(self.timeout_spin.value()), + self.connect_checkbox.isChecked(), + self.force_connect_checkbox.isChecked(), + ) + super().accept() + + def result(self): + return self._result + + +class ValidationListItem(QtWidgets.QWidget): + """List item to display device test validation status.""" + + request_rerun_validation = QtCore.Signal(str, dict, bool, bool, float) + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + device_model: DeviceTestModel | None = None, + validation_icons: ( + dict[Literal["config_status", "connection_status"], dict[int, QtGui.QIcon]] | None + ) = None, + icon_size: tuple[int, int] = (32, 32), + ) -> None: + super().__init__(parent=parent) + if device_model is None: + logger.debug("No device config provided to ValidationListItem.") + return + self.device_model: DeviceTestModel = device_model + self.is_running: bool = False + self._colors = get_accent_colors() + self._icon_size = icon_size + self._validation_icons = validation_icons or get_validation_icons( + colors=self._colors, icon_size=self._icon_size, convert_to_pixmap=False + ) + + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self._setup_ui() + + ###################### + ### UI Setup Methods + ###################### + + def _setup_ui(self) -> None: + """Setup the UI elements of the widget.""" + # Device Name Label + label = QtWidgets.QLabel(self.device_model.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + + button_layout = QtWidgets.QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(8) + + # Spinner + self._spinner = SpinnerWidget() + self._spinner.speed = 80 + self._spinner.setFixedSize(self._icon_size[0] // 1.5, self._icon_size[1] // 1.5) + self._spinner.setVisible(False) + + # Add to button layout + button_layout.addWidget(self._spinner) + + # Config Status Icon + self.status_button = ValidationButton( + icon=self._validation_icons["config_status"][self.device_model.config_status] + ) + self.status_button.setToolTip("Configuration Status") + self.status_button.clicked.connect(self._on_status_button_clicked) + button_layout.addWidget(self.status_button) + + # Connection Status Icon + self.connection_button = ValidationButton( + icon=self._validation_icons["connection_status"][self.device_model.connection_status] + ) + self.connection_button.setToolTip("Connection Status") + self.connection_button.clicked.connect(self._on_connection_button_clicked) + button_layout.addWidget(self.connection_button) + self.main_layout.addLayout(button_layout) + + ####################### + ### Event Handlers + ####################### + + def _on_status_button_clicked(self) -> None: + """Handle status button click event.""" + timeout, connect, force_connect = 5, False, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + def _on_connection_button_clicked(self) -> None: + """Handle connection button click event.""" + timeout, connect, force_connect = 5, True, False + dialog = self._create_validation_dialog_box(timeout, connect, force_connect) + if dialog.exec(): # Only procs in success + timeout, connect, force_connect = dialog.result() + self.request_rerun_validation.emit( + self.device_model.device_name, + self.device_model.model_dump(), + connect, + force_connect, + timeout, + ) + + ######################### + ### Helper Methods + ######################### + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + def _create_validation_dialog_box( + self, timeout: float, connect: bool, force_connect: bool + ) -> QtWidgets.QDialog: + """Create a dialog box to confirm re-validation.""" + return ValidationDialog( + parent=self, timeout=timeout, connect=connect, force_connect=force_connect + ) + + def _update_validation_status( + self, validation_msg: str, config_status: int, connection_status: int + ): + """ + Update the validation status icons and message. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + # Update device config model + self.device_model.validation_msg = validation_msg + self.device_model.config_status = ConfigStatus(config_status).value + self.device_model.connection_status = ConnectionStatus(connection_status).value + + # Update icons + self.status_button.setIcon( + self._validation_icons["config_status"][self.device_model.config_status] + ) + self.connection_button.setIcon( + self._validation_icons["connection_status"][self.device_model.connection_status] + ) + + ########################## + ### Public Methods + ########################## + + @SafeSlot(str, int, int) + def on_validation_finished( + self, validation_msg: str, config_status: int, connection_status: int + ): + """Handle validation finished event. + + Args: + validation_msg (str): The validation message. + config_status (int): The configuration status. + connection_status (int): The connection status. + """ + self.is_running = False + self._stop_spinner() + self._update_validation_status(validation_msg, config_status, connection_status) + + # Enable/disable buttons based on status + config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID] + self.status_button.setEnabled(config_but_en) + self.status_button.set_enabled_style(config_but_en) + connect_but_en = connection_status in [ + ConnectionStatus.UNKNOWN, + ConnectionStatus.CANNOT_CONNECT, + ] + self.connection_button.setEnabled(connect_but_en) + self.connection_button.set_enabled_style(connect_but_en) + + @SafeSlot() + def validation_scheduled(self): + """Handle validation scheduled event.""" + self._update_validation_status( + "Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + self.status_button.setEnabled(False) + self.status_button.set_enabled_style(False) + self.connection_button.setEnabled(False) + self.connection_button.set_enabled_style(False) + self._spinner.setVisible(True) + + @SafeSlot() + def validation_started(self): + """Start validation process.""" + self.is_running = True + self._start_spinner() + self._update_validation_status( + "Validation running...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN + ) + + @SafeSlot() + def start_validation(self): + """Start validation process.""" + self.validation_scheduled() + self.validation_started() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("dark") + w = QtWidgets.QWidget() + l = QtWidgets.QVBoxLayout(w) + + # Example device model + device_model = DeviceTestModel( + uuid="1234", + device_name="Test Device", + device_config={"param1": "value1"}, + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, + validation_msg="Initial validation failed.", + ) + + # Create validation list item + validation_item = ValidationListItem(parent=w, device_model=device_model) + l.addWidget(validation_item) + + but = QtWidgets.QPushButton("Start Validation") + but2 = QtWidgets.QPushButton("Finish Validation") + but.clicked.connect(validation_item.start_validation) + but2.clicked.connect( + lambda: validation_item.on_validation_finished( + "Validation successful.", + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ) + ) + l.addWidget(but) + l.addWidget(but2) + + def _print_callback(name, cfg, conn, force, to): + print( + f"Re-run validation requested for dev {name} for config {cfg} with timeout={to}, connect={conn}, force={force}" + ) + + validation_item.request_rerun_validation.connect(_print_callback) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/device_manager.py b/bec_widgets/widgets/control/device_manager/device_manager.py deleted file mode 100644 index 04178cae5..000000000 --- a/bec_widgets/widgets/control/device_manager/device_manager.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -This module provides an implementation for the device config view. -The widget is the entry point for users to edit device configurations. -""" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 47a7a1c76..6f81a4cf8 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -15,6 +15,10 @@ from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module from bec_widgets.utils import error_popups +# Patch to set default RAISE_ERROR_DEFAULT to True for tests +# This means that by default, error popups will raise exceptions during tests +# error_popups.RAISE_ERROR_DEFAULT = True + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index b4454cfd7..9964a396a 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -1,67 +1,90 @@ """Unit tests for device_manager_components module.""" +from threading import Event +from typing import Generator from unittest import mock import pytest import yaml from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import ( + OPHYD_DEVICE_TEMPLATES, + EpicsMotorDeviceConfigTemplate, +) +from ophyd_devices.utils.static_device_test import TestResult from qtpy import QtCore, QtGui, QtWidgets +from bec_widgets.utils.bec_list import BECList +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.widgets.control.device_manager import DeviceTable, DMConfigView, DocstringView +from bec_widgets.widgets.control.device_manager.components import docstring_to_markdown from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD -from bec_widgets.widgets.control.device_manager.components.device_table_view import ( - USER_CHECK_DATA_ROLE, - BECTableView, - CenterCheckBoxDelegate, - CustomDisplayDelegate, - DeviceFilterProxyModel, - DeviceTableModel, - DeviceTableView, - DeviceValidatedDelegate, - DictToolTipDelegate, - WrappingTextDelegate, +from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import ( + DeviceConfigTemplate, ) -from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView -from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import ( - DocstringView, - docstring_to_markdown, +from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import ( + DEVICE_CONFIG_FIELDS, + DEVICE_FIELDS, + DeviceConfigField, + DeviceTagsWidget, + InputLineEdit, + LimitInputWidget, + OnFailureComboBox, + ParameterValueWidget, + ReadoutPriorityComboBox, + _try_literal_eval, ) -from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus - - -### Constants #### -def test_constants_headers_help_md(): - """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" - assert isinstance(HEADERS_HELP_MD, dict) - expected_keys = { - "status", - "name", - "deviceClass", - "readoutPriority", - "deviceTags", - "enabled", - "readOnly", - "onFailure", - "softwareTrigger", - "description", - } - assert set(HEADERS_HELP_MD.keys()) == expected_keys - for _, value in HEADERS_HELP_MD.items(): - assert isinstance(value, str) - assert value.startswith("## ") # Each entry should start with a markdown header - - -### DM Docstring View #### - - -@pytest.fixture -def docstring_view(qtbot): - """Fixture to create a DocstringView instance.""" - view = DocstringView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view - - +from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import ( + DeviceTableRow, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + DeviceTest, + LegendLabel, + OphydValidation, + ThreadPoolManager, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConfigStatus, + ConnectionStatus, + DeviceTestModel, + format_error_to_md, + get_validation_icons, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.validation_list_item import ( + ValidationButton, + ValidationDialog, + ValidationListItem, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch + + +class TestConstants: + """Test class for constants and configuration values.""" + + def test_headers_help_md(self): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "valid", + "connect", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value["long"], str) + assert isinstance(value["short"], str) + assert value["long"].startswith("## ") # Each entry should start with a markdown header + + +# Test utility classes for docstring testing class NumPyStyleClass: """Perform simple signal operations. @@ -97,773 +120,1440 @@ class GoogleStyleClass: """ -def test_docstring_view_docstring_to_markdown(): - """Test the docstring_to_markdown function with a sample class.""" - numpy_md = docstring_to_markdown(NumPyStyleClass) - assert "# NumPyStyleClass" in numpy_md - assert "### Parameters" in numpy_md - assert "### Attributes" in numpy_md - assert "### Returns" in numpy_md - assert "```" in numpy_md # Check for code block formatting - - google_md = docstring_to_markdown(GoogleStyleClass) - assert "# GoogleStyleClass" in google_md - assert "### Args" in google_md - assert "### Returns" in google_md - assert "### Raises" in google_md - assert "```" in google_md # Check for code block formatting - - -def test_docstring_view_on_select_config(docstring_view): - """Test the DocstringView on_select_config method. Called with single and multiple devices.""" - with ( - mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, - mock.patch.object(docstring_view, "_set_text") as mock_set_text, - ): - # Test with single device - docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}]) - mock_set_device_class.assert_called_once_with("NumPyStyleClass") - - mock_set_device_class.reset_mock() - # Test with multiple devices, should not show anything - docstring_view.on_select_config( - [{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}] - ) - mock_set_device_class.assert_not_called() - mock_set_text.assert_called_once_with("") - - -def test_docstring_view_set_device_class(docstring_view): - """Test the DocstringView set_device_class method with valid and invalid class names.""" - with mock.patch( - "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" - ) as mock_get_plugin_class: - - # Mock a valid class retrieval - mock_get_plugin_class.return_value = NumPyStyleClass - docstring_view.set_device_class("NumPyStyleClass") - assert "NumPyStyleClass" in docstring_view.toPlainText() - assert "Parameters" in docstring_view.toPlainText() - - # Mock an invalid class retrieval - mock_get_plugin_class.side_effect = ImportError("Class not found") - docstring_view.set_device_class("NonExistentClass") - assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() - - # Test if READY_TO_VIEW is False - with mock.patch( - "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", - False, +class TestDocstringView: + """Test class for DocstringView component.""" + + @pytest.fixture + def docstring_view(self, qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + def test_docstring_to_markdown(self): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting + + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting + + def test_on_select_config(self, docstring_view: DocstringView): + """Test the on_select_config method with a sample configuration.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, ): - call_count = mock_get_plugin_class.call_count - docstring_view.set_device_class("NumPyStyleClass") # Should do nothing - assert mock_get_plugin_class.call_count == call_count # No new calls made - - -#### DM Config View #### + # Test with single device + docstring_view.on_select_config([{"test": {"deviceClass": "NumPyStyleClass"}}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [ + {"test": {"deviceClass": "NumPyStyleClass"}}, + {"test": {"deviceClass": "GoogleStyleClass"}}, + ] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + def test_set_device_class(self, docstring_view: DocstringView): + """Test the set_device_class method.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +class TestDMConfigView: + """Test class for DMConfigView component.""" + + @pytest.fixture + def dm_config_view(self, qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + def test_initialization(self, dm_config_view: DMConfigView): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select a single device to view its config." + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + def test_on_select_config(self, dm_config_view: DMConfigView): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +class TestDeviceTableRow: + """Test class for DeviceTableRow component.""" + + @pytest.fixture + def sample_device_data(self) -> dict: + """Sample device data for testing.""" + return { + "name": "test_motor", + "deviceClass": "ophyd.EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": {"motors", "positioning"}, + "description": "X-axis positioning motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + } + + @pytest.fixture + def device_table_row(self, sample_device_data: dict): + """Fixture to create a DeviceTableRow instance.""" + row = DeviceTableRow(data=sample_device_data) + yield row + + def test_initialization(self, device_table_row: DeviceTableRow, sample_device_data: dict): + """Test DeviceTableRow initialization with sample data.""" + expected_keys = list(DeviceModel.model_fields.keys()) + for key in expected_keys: + assert key in device_table_row.data + if key in sample_device_data: + assert device_table_row.data[key] == sample_device_data[key] + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, + ) + device_table_row.set_validation_status(ConfigStatus.VALID, ConnectionStatus.CONNECTED) + assert device_table_row.validation_status == ( + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + new_data = sample_device_data.copy() + new_data["name"] = "updated_motor" + device_table_row.set_data(new_data) + assert device_table_row.data["name"] == new_data.get("name", "") + assert device_table_row.validation_status == ( + ConfigStatus.UNKNOWN, + ConnectionStatus.UNKNOWN, + ) -@pytest.fixture -def dm_config_view(qtbot): - """Fixture to create a DMConfigView instance.""" - view = DMConfigView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view +class TestDeviceTable: + """Test class for DeviceTable component.""" + + @pytest.fixture + def device_table(self, qtbot) -> Generator[DeviceTable, None, None]: + """Fixture to create a DeviceTable instance.""" + table = DeviceTable() + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table + + @pytest.fixture + def sample_devices(self): + """Sample device configurations for testing.""" + return [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] + + def test_initialization(self, device_table: DeviceTable): + """Test DeviceTable initialization.""" + # Check table setup + assert device_table.table.columnCount() == 11 + assert device_table.table.rowCount() == 0 + + # Check headers + expected_headers = [ + "Valid", + "Connect", + "Name", + "Device Class", + "Readout Priority", + "On Failure", + "Device Tags", + "Description", + "Enabled", + "Read Only", + "Software Trigger", + ] + for i, expected_header in enumerate(expected_headers): + actual_header = device_table.table.horizontalHeaderItem(i).text() + assert actual_header == expected_header + + # Check search functionality is set up + assert device_table.search_input is not None + assert device_table.fuzzy_is_disabled.isChecked() is False + assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows + + def test_add_row(self, device_table: DeviceTable, sample_devices: dict): + """Test adding a single device row.""" + device_table.add_device_configs([sample_devices[0]]) + + # Verify row was added + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + assert "motor_x" in device_table.row_data + + # If row is added again, it should overwrite + sample_devices[0]["deviceClass"] = "UpdateClass" + device_table.add_device_configs([sample_devices[0]]) + assert device_table.table.rowCount() == 1 + assert len(device_table.row_data) == 1 + row_data = device_table.row_data["motor_x"] + assert row_data is not None + assert row_data.data.get("deviceClass") == "UpdateClass" + assert device_table._get_cell_data(0, 3) == "UpdateClass" # DeviceClass column + assert device_table._get_cell_data(0, 2) == "motor_x" # Name column + assert device_table._get_cell_data(0, 0) == "" # Icon column, no text + assert device_table._get_cell_data(0, 9) == False # Check Enabled column + assert device_table.table.item(0, 9).checkState() == QtCore.Qt.CheckState.Unchecked + config_status_item = device_table.table.item(0, 0) + assert ( + config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.UNKNOWN.value + ) + def test_update_row(self, device_table: DeviceTable, sample_devices: dict): + """Test updating an existing device row.""" + device_table.add_device_configs([sample_devices[0]]) + + assert "motor_x" in device_table.row_data + # Update the existing row + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "X-axis motor" + # Change description + sample_devices[0]["description"] = "Updated X-axis motor" + device_table._update_row(sample_devices[0]) + row: DeviceTableRow = device_table.row_data["motor_x"] + assert row.data["description"] == "Updated X-axis motor" + assert row.validation_status == (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) + # Update validation status + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + validation_msg="", + ) + assert row.validation_status == (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value) + config_status_item = device_table.table.item(0, 0) + assert config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.VALID.value + + ##################### + ##### Test public API + ##################### + + def test_set_device_config(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test set device configs methods, must also emit the appropriate signal.""" + with mock.patch.object(device_table, "clear_device_configs") as mock_clear_configs: + ########### + # Test cases I. + # First use case, adding new configs to empty table + device_table.set_device_config(sample_devices) + + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 1 + + # II. + # Second use case, replacing existing configs + device_table.set_device_config(sample_devices) + assert device_table.table.rowCount() == 2 + assert mock_clear_configs.call_count == 2 + + def test_clear_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test clearing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.clear_device_configs() + + assert len(container) == 1 + assert device_table.table.rowCount() == 0 + + def test_add_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test add device configs method under various scenarios.""" + + ########## + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + ########### + # Test cases + # I. + # First use case, adding new configs to empty table + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + assert device_table.table.rowCount() == 2 + + # II. + # If added again, old configs should be removed first, and new ones added + # Reset container + container = [] + expected_calls = 2 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.add_device_configs(sample_devices) + + assert len(container) == 2 + assert container[0][0][1] is False + assert container[1][0][0] == sample_devices + assert container[1][0][1] is True + + # Verify rows were added + assert device_table.table.rowCount() == 2 + assert len(device_table.row_data) == 2 + assert "motor_x" in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_update_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test updating device configurations.""" + + # Callbacks + container = [] + + def _config_changed_cb(*args, **kwargs): + container.append((args, kwargs)) + + device_table.device_configs_changed.connect(_config_changed_cb) + + # First case I. + # Update to empty table should add rows, and emit signal with added=True + expected_calls = 1 + + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls) as blocker: + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == sample_devices + assert container[0][0][1] is True + + # Second case II. + # Update existing configs should modify rows, and change the validation status to unknown + # for the device that was changed + container = [] + sample_devices[0]["description"] = "Modified description" + expected_calls = 1 + with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls): + device_table.update_device_configs(sample_devices) + + # Verify signal emission + assert len(container) == 1 + assert container[0][0][0] == [sample_devices[0]] + assert container[0][0][1] is True + + def test_get_device_config(self, device_table: DeviceTable, sample_devices: dict): + """Test retrieving device configurations.""" + device_table.add_device_configs(sample_devices) + + retrieved_configs = device_table.get_device_config() + assert len(retrieved_configs) == 2 + + # Check that we can find our test devices + device_names = [config["name"] for config in retrieved_configs] + assert "motor_x" in device_names + assert "detector_main" in device_names + + def test_search_functionality(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test search/filter functionality.""" + device_table.add_device_configs(sample_devices) + + # Test filtering by name + qtbot.keyClicks(device_table.search_input, "motor") + qtbot.wait(100) # Allow filter to apply + + # Should show only motor device + visible_rows = 0 + for row in range(device_table.table.rowCount()): + if not device_table.table.isRowHidden(row): + visible_rows += 1 + assert visible_rows == 1 + + def test_remove_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test removing device configurations.""" + device_table.add_device_configs(sample_devices) + assert device_table.table.rowCount() == 2 + + # Remove one device + with qtbot.waitSignal(device_table.device_configs_changed) as blocker: + device_table.remove_device_configs([sample_devices[0]]) + + # Verify signal emission + emitted_configs, added = blocker.args + assert len(emitted_configs) == 1 + assert added is False + + # Verify row was removed + assert device_table.table.rowCount() == 1 + assert "motor_x" not in device_table.row_data + assert "detector_main" in device_table.row_data + + def test_validation_status_update(self, device_table: DeviceTable, sample_devices: dict): + """Test updating validation status.""" + device_table: DeviceTable + device_table.add_device_configs(sample_devices) + + # Update validation status for one device + device_table.update_device_validation( + sample_devices[0], + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + validation_msg="Test passed", + ) -def test_dm_config_view_initialization(dm_config_view): - """Test DMConfigView proper initialization.""" - # Check that the stacked layout is set up correctly - assert dm_config_view.stacked_layout is not None - assert dm_config_view.stacked_layout.count() == 2 - # Assert Monaco editor is initialized - assert dm_config_view.monaco_editor.get_language() == "yaml" - assert dm_config_view.monaco_editor.editor._readonly is True + # Verify status was updated in the row + motor_row = device_table.row_data["motor_x"] + assert motor_row.validation_status == (ConfigStatus.VALID, ConnectionStatus.CONNECTED) - # Check overlay widget - assert dm_config_view._overlay_widget is not None - assert dm_config_view._overlay_widget.text() == "Select single device to show config" + def test_selection_handling(self, device_table: DeviceTable, sample_devices: dict, qtbot): + """Test device selection and signal emission.""" + device_table.add_device_configs(sample_devices) - # Check that overlay is initially shown - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + # Select first row + with qtbot.waitSignal(device_table.selected_devices) as blocker: + device_table.table.selectRow(0) + # Verify selection signal was emitted + selected_configs = blocker.args[0] + assert len(selected_configs) == 1 + assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"] -def test_dm_config_view_on_select_config(dm_config_view): - """Test DMConfigView on_select_config with empty selection.""" - # Test with empty list of configs - dm_config_view.on_select_config([]) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget - # Test with a single config - cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] - dm_config_view.on_select_config(cfgs) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor - text = yaml.dump(cfgs[0], default_flow_style=False) - assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") - - # Test with multiple configs - cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] - dm_config_view.on_select_config(cfgs) - assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget - assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged - - -### Device Table View #### -# Not sure how to nicely test the delegates. - - -@pytest.fixture -def mock_table_view(qtbot): - """Create a mock table view for delegate testing.""" - table = BECTableView() - qtbot.addWidget(table) - qtbot.waitExposed(table) - yield table - - -@pytest.fixture -def device_table_model(qtbot, mock_table_view): - """Fixture to create a DeviceTableModel instance.""" - model = DeviceTableModel(mock_table_view) - yield model - - -@pytest.fixture -def device_proxy_model(qtbot, mock_table_view, device_table_model): - """Fixture to create a DeviceFilterProxyModel instance.""" - model = DeviceFilterProxyModel(mock_table_view) - model.setSourceModel(device_table_model) - mock_table_view.setModel(model) - yield model - - -@pytest.fixture -def qevent_mock() -> QtCore.QEvent: - """Create a mock QEvent for testing.""" - event = mock.MagicMock(spec=QtCore.QEvent) - yield event - - -@pytest.fixture -def view_mock() -> QtWidgets.QAbstractItemView: - """Create a mock QAbstractItemView for testing.""" - view = mock.MagicMock(spec=QtWidgets.QAbstractItemView) - yield view - - -@pytest.fixture -def index_mock(device_proxy_model) -> QtCore.QModelIndex: - """Create a mock QModelIndex for testing.""" - index = mock.MagicMock(spec=QtCore.QModelIndex) - index.model.return_value = device_proxy_model - yield index +class TestOphydValidation: + """ + Test class for the Ophyd test module. This tests the OphydValidation widget, + the validation list items and dialog, and the utility functions related to + device testing and validation. + """ - -@pytest.fixture -def option_mock() -> QtWidgets.QStyleOptionViewItem: - """Create a mock QStyleOptionViewItem for testing.""" - option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem) - yield option - - -@pytest.fixture -def painter_mock() -> QtGui.QPainter: - """Create a mock QPainter for testing.""" - painter = mock.MagicMock(spec=QtGui.QPainter) - yield painter - - -def test_tooltip_delegate( - mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model -): - """Test DictToolTipDelegate tooltip generation.""" - # No ToolTip event - delegate = DictToolTipDelegate(mock_table_view) - qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel - # nothing should happen - with mock.patch.object( - QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False - ) as super_mock: - result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) - - super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock) - assert result is False - - # ToolTip event - qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip - qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20)) - - source_model = device_proxy_model.sourceModel() - with ( - mock.patch.object( - source_model, "get_row_data", return_value={"description": "Mock description"} - ), - mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock), - mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock, - ): - result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) - show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock) - assert result is True - - -def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock): - """Test CustomDisplayDelegate initialization.""" - delegate = CustomDisplayDelegate(mock_table_view) - - # Test _test_custom_paint, with None and a value - def _return_data(): - yield None - yield "Test Value" - - proxy_model = index_mock.model() - with ( - mock.patch.object(proxy_model, "data", side_effect=_return_data()), - mock.patch.object( - QtWidgets.QStyledItemDelegate, "paint", return_value=None - ) as super_paint_mock, - mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock, - ): - delegate.paint(painter_mock, option_mock, index_mock) - super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock) - custom_paint_mock.assert_not_called() - # Call again for the value case - delegate.paint(painter_mock, option_mock, index_mock) - super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock) - assert super_paint_mock.call_count == 2 - custom_paint_mock.assert_called_once_with( - painter_mock, option_mock, index_mock, "Test Value" + ################ + ### Ophyd_test_utils tests + ################ + + def test_format_error_to_md(self): + """Test the format_error_to_md utility function.""" + device_name = "non_existing_device" + error_msg = """ERROR: non_existing_device is not valid: 3 validation errors for Device\nenabled\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\ndeviceClass\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nreadoutPriority\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nERROR: non_existing_device is not valid: 'deviceClass'""" + md_output = format_error_to_md(device_name, error_msg) + assert f"## Error for {device_name}\n\n**{device_name} is not valid**" in md_output + assert "3 validation errors for Device" in md_output + + def test_description_validation_status(self): + """Test descriptions for ConfigStatus enum values.""" + # ConfigStatus descriptions + assert ConfigStatus.VALID.description() == "Valid Configuration" + assert ConfigStatus.INVALID.description() == "Invalid Configuration" + assert ConfigStatus.UNKNOWN.description() == "Unknown" + + # ConnectionStatus descriptions + assert ConnectionStatus.CANNOT_CONNECT.description() == "Cannot Connect" + assert ConnectionStatus.CAN_CONNECT.description() == "Can Connect" + assert ConnectionStatus.CONNECTED.description() == "Connected and Loaded" + assert ConnectionStatus.UNKNOWN.description() == "Unknown" + + def test_device_test_model(self): + """Test the DeviceTestModel""" + data = { + "uuid": "1234", + "device_name": "test_device", + "device_config": {"name": "test_device", "deviceClass": "TestClass"}, + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + assert model.uuid == "1234" + assert model.device_name == "test_device" + + def test_get_validation_icons(self): + """Test the get_validation_icons utility function.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + + # Check that icons for all statuses are present + for status in ConfigStatus: + assert status in icons["config_status"] + assert isinstance(icons["config_status"][status], QtGui.QIcon) + + for status in ConnectionStatus: + assert status in icons["connection_status"] + assert isinstance(icons["connection_status"][status], QtGui.QIcon) + + ################ + ### ValidationListItem tests + ################ + + @pytest.fixture + def validation_button(self, qtbot): + """Fixture to create a ValidationButton instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + icon = icons["config_status"][ConfigStatus.VALID.value] + button = ValidationButton(icon=icon) + + qtbot.addWidget(button) + qtbot.waitExposed(button) + yield button + + def test_validation_button_initialization(self, validation_button: ValidationButton): + """Test ValidationButton initialization.""" + assert validation_button.isFlat() is True + assert validation_button.isEnabled() is True + assert isinstance(validation_button.icon(), QtGui.QIcon) + assert validation_button.styleSheet() == "" + validation_button.setEnabled(False) + assert validation_button.styleSheet() == validation_button.transparent_style + + @pytest.fixture + def validation_dialog(self, qtbot): + """Fixture for ValidationDialog.""" + dialog = ValidationDialog() + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + def test_validation_dialog(self, validation_dialog: ValidationDialog, qtbot): + """Test ValidationDialog initialization.""" + assert validation_dialog.timeout_spin.value() == 5 + assert validation_dialog.connect_checkbox.isChecked() is False + assert validation_dialog.force_connect_checkbox.isChecked() is False + + # Change timeout + validation_dialog.timeout_spin.setValue(10) + # Result should not update yet + assert validation_dialog.result() == (5, False, False) + # Click accept + with qtbot.waitSignal(validation_dialog.accepted): + qtbot.mouseClick( + validation_dialog.button_box.button(QtWidgets.QDialogButtonBox.Ok), + QtCore.Qt.LeftButton, + ) + assert validation_dialog.result() == (10, False, False) + + @pytest.fixture + def device_model(self): + """Fixture to create a sample DeviceTestModel instance.""" + config = DeviceModel( + name="test_device", deviceClass="TestClass", readoutPriority="baseline", enabled=True ) - - -def test_center_checkbox_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test CenterCheckBoxDelegate initialization.""" - delegate = CenterCheckBoxDelegate(mock_table_view) - - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked) - # Check that the checkbox is centered - pixrect = delegate._icon_checked.rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked) - - model = index_mock.model() - - # Editor event with non-check state role - qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange - assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock) - - # Editor event with check state role but not mouse button event - qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease - with ( - mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked), - mock.patch.object(model, "setData") as mock_model_set, - ): - delegate.editorEvent(qevent_mock, model, option_mock, index_mock) - mock_model_set.assert_called_once_with( - index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE + data = { + "uuid": "1234", + "device_name": config.name, + "device_config": config.model_dump(), + "config_status": ConfigStatus.VALID.value, + "connection_status": ConnectionStatus.CONNECTED.value, + "validation_messages": "All good", + } + model = DeviceTestModel.model_validate(data) + yield model + + @pytest.fixture + def validation_list_item(self, device_model, qtbot): + """Fixture to create a ValidationListItem instance.""" + colors = get_accent_colors() + icons = get_validation_icons(colors, (16, 16)) + item = ValidationListItem(device_model=device_model, validation_icons=icons) + qtbot.addWidget(item) + qtbot.waitExposed(item) + yield item + + def test_update_validation_status(self, validation_list_item: ValidationListItem): + """Test updating status in ValidationListItem.""" + # Update to invalid config status + validation_list_item._update_validation_status( + validation_msg="Error occurred", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.CANNOT_CONNECT.value, ) + assert validation_list_item.device_model.config_status == ConfigStatus.INVALID.value + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CANNOT_CONNECT.value + ) + assert validation_list_item.device_model.validation_msg == "Error occurred" + + def test_validation_logic(self, validation_list_item: ValidationListItem): + """Test starting and stopping validation spinner.""" + # Schedule validation + validation_list_item.validation_scheduled() + assert validation_list_item.status_button.isEnabled() is False + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.is_running is False + + # Start validation + with mock.patch.object(validation_list_item._spinner, "start") as mock_spinner_start: + validation_list_item.start_validation() + assert validation_list_item.is_running is True + mock_spinner_start.assert_called_once() + + # Finish validation + + with mock.patch.object(validation_list_item._spinner, "stop") as mock_spinner_stop: + + # I. successful validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.VALID.value, + connection_status=ConnectionStatus.CAN_CONNECT.value, + ) + assert validation_list_item.is_running is False + assert ( + validation_list_item.device_model.connection_status + == ConnectionStatus.CAN_CONNECT.value + ) + mock_spinner_stop.assert_called_once() + # Buttons should be disabled after validation finished good + assert validation_list_item.connection_button.isEnabled() is False + assert validation_list_item.status_button.isEnabled() is False + + # Restart validation + validation_list_item.start_validation() + mock_spinner_stop.reset_mock() + + # II. failed validation + validation_list_item.on_validation_finished( + validation_msg="Finished", + config_status=ConfigStatus.INVALID.value, + connection_status=ConnectionStatus.UNKNOWN.value, + ) + assert validation_list_item.is_running is False + mock_spinner_stop.assert_called_once() + assert validation_list_item.connection_button.isEnabled() is True + assert validation_list_item.status_button.isEnabled() is True + + #################### + ### OphydValidation widget tests + #################### + + @pytest.fixture + def device_test_runnable(self, device_model, qtbot): + """Fixture to create a DeviceTest instance.""" + widget = QtWidgets.QWidget() # Create a widget because the runnable is not a widget itself + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + widget._runnable_test = DeviceTest( + device_model=device_model, timeout=5, enable_connect=True, force_connect=False + ) + yield widget + def test_device_test(self, device_test_runnable, qtbot): + """Test DeviceTest runnable initialization.""" + runnable: DeviceTest = device_test_runnable._runnable_test + assert runnable.device_config.get("name") == "test_device" + assert runnable.timeout == 5 + assert runnable.enable_connect is True + assert runnable._cancelled is False -def test_device_validated_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test DeviceValidatedDelegate initialization.""" - # Invalid value - delegate = DeviceValidatedDelegate(mock_table_view) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value") - painter_mock.drawPixmap.assert_not_called() - - # Valid value - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value) - icon = delegate._icons[ValidationStatus.VALID.value] - pixrect = icon.rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon) - - -def test_wrapping_text_delegate_do_custom_paint( - mock_table_view, painter_mock, option_mock, index_mock -): - """Test WrappingTextDelegate _do_custom_paint method.""" - delegate = WrappingTextDelegate(mock_table_view) - - # First case, empty text, nothing should happen - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "") - painter_mock.setPen.assert_not_called() - layout_mock = mock.MagicMock() - - def _layout_comput_return(*args, **kwargs): - return layout_mock - - layout_mock.draw.return_value = None - with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return): - delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring") - layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft()) - - -TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20) -TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate." - - -def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock): - """Test WrappingTextDelegate _compute_layout method.""" - delegate = WrappingTextDelegate(mock_table_view) - layout_mock = mock.MagicMock(spec=QtGui.QTextLayout) - - # This combination should yield 4 lines - with mock.patch.object(delegate, "_get_layout", return_value=layout_mock): - layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine) - mock_line.height.return_value = 10 - mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False]) - - option_mock.rect = TEST_RECT_FOR - option_mock.font = QtGui.QFont() - layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock) - assert layout.createLine.call_count == 4 # pylint: disable=E1101 - assert mock_line.setPosition.call_count == 3 - assert mock_line.setPosition.call_args_list[-1] == mock.call( - QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit - ) + # Callback validation + container = [] + def _runnable_callback( + config: dict, config_is_valid: bool, connection_status: bool, error_msg: str + ): + container.append((config, config_is_valid, connection_status, error_msg)) -def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock): - """Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines.""" - delegate = WrappingTextDelegate(mock_table_view) - assert delegate.margin == 6 - with ( - mock.patch.object(mock_table_view, "initViewItemOption"), - mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]), - mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]), - ): - # Test with empty text, should return height + 2*margin - index_mock.data.return_value = "" - option_mock.rect = TEST_RECT_FOR - font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont()) - size = delegate.sizeHint(option_mock, index_mock) - assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin) - - # Now test with the text that should wrap to 4 lines - index_mock.data.return_value = TEST_TEXT_WITH_4_LINES - size = delegate.sizeHint(option_mock, index_mock) - # The estimate goes to 5 lines + 2* margin - expected_lines = 5 - assert size == QtCore.QSize( - 100, font_metrics.height() * expected_lines + 2 * delegate.margin - ) + runnable.signals.device_validated.connect(_runnable_callback) + # Callback started + started_container = [] -def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model): - """Test WrappingTextDelegate update_row_heights method.""" - device_cfg = DeviceModel( - name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline" - ).model_dump() - # Add single device to config - delegate = WrappingTextDelegate(mock_table_view) - row_heights = [25, 40] + def _runnable_started_callback(): + started_container.append(True) - with mock.patch.object( - delegate, - "sizeHint", - side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])], - ): - mock_table_view.setItemDelegateForColumn(5, delegate) - mock_table_view.setItemDelegateForColumn(6, delegate) - device_proxy_model.sourceModel().set_device_config([device_cfg]) - assert delegate._wrapping_text_columns is None - assert mock_table_view.rowHeight(0) == 30 # Default height - delegate._update_row_heights() - assert delegate._wrapping_text_columns == [5, 6] - assert mock_table_view.rowHeight(0) == max(row_heights) - - -def test_device_validation_delegate( - mock_table_view, qevent_mock, painter_mock, option_mock, index_mock -): - """Test DeviceValidatedDelegate initialization.""" - delegate = DeviceValidatedDelegate(mock_table_view) - - option_mock.rect = QtCore.QRect(0, 0, 100, 20) - delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID) - # Check that the checkbox is centered - - pixrect = delegate._icons[ValidationStatus.VALID.value].rect() - pixrect.moveCenter(option_mock.rect.center()) - painter_mock.drawPixmap.assert_called_once_with( - pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value] - ) + runnable.signals.device_validation_started.connect(_runnable_started_callback) - # Should not be called if invalid value - delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10) - - # Check that the checkbox is centered - assert painter_mock.drawPixmap.call_count == 1 - - -### -# Test DeviceTableModel & DeviceFilterProxyModel -### - - -def test_device_table_model_data(device_proxy_model): - """Test the device table model data retrieval.""" - source_model = device_proxy_model.sourceModel() - test_device = { - "status": ValidationStatus.PENDING, - "name": "test_device", - "deviceClass": "TestClass", - "readoutPriority": "baseline", - "onFailure": "retry", - "enabled": True, - "readOnly": False, - "softwareTrigger": True, - "deviceTags": ["tag1", "tag2"], - "description": "Test device", - } - source_model.add_device_configs([test_device]) - assert source_model.rowCount() == 1 - assert source_model.columnCount() == 10 - - # Check data retrieval for each column - expected_data = { - 0: ValidationStatus.PENDING, # Default status - 1: "test_device", # name - 2: "TestClass", # deviceClass - 3: "baseline", # readoutPriority - 4: "retry", # onFailure - 5: "tag1, tag2", # deviceTags - 6: "Test device", # description - 7: True, # enabled - 8: False, # readOnly - 9: True, # softwareTrigger - } - - for col, expected in expected_data.items(): - index = source_model.index(0, col) - data = source_model.data(index, QtCore.Qt.DisplayRole) - assert data == expected - - -def test_device_table_model_with_data(device_table_model, device_proxy_model): - """Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data.""" - # Create 3 test devices - names NOT alphabetically sorted - test_devices = [ - { - "name": "zebra_device", - "deviceClass": "TestClass1", + # Should resolve without running test if cancelled + runnable.cancel() + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.UNKNOWN.value + assert connection_status == ConnectionStatus.UNKNOWN.value + assert error_msg == f"{runnable.device_config.get('name', '')} was cancelled by user." + + # Now we run it without cancelling + + # Reset containers + container = [] + started_container = [] + runnable._cancelled = False + with mock.patch.object( + runnable.tester, "run_with_list_output" + ) as mock_run_with_list_output: + mock_run_with_list_output.return_value = [ + TestResult( + name="test_device", + config_is_valid=ConfigStatus.VALID.value, + success=ConnectionStatus.CANNOT_CONNECT.value, + message="All good", + ) + ] + with qtbot.waitSignals( + [runnable.signals.device_validation_started, runnable.signals.device_validated] + ): + runnable.run() + assert len(started_container) == 1 + assert len(container) == 1 + config, config_is_valid, connection_status, error_msg = container[0] + assert config == runnable.device_config + assert config_is_valid == ConfigStatus.VALID.value + assert connection_status == ConnectionStatus.CANNOT_CONNECT.value + assert error_msg == "All good" + + @pytest.fixture + def thread_pool_manager(self, qtbot): + """Fixture to create a ThreadPoolManager instance.""" + widget = QtWidgets.QWidget() # Create a widget because the manager is not a widget itself + widget._pool_manager = ThreadPoolManager() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_thread_pool_manager(self, thread_pool_manager): + """Test ThreadPoolManager initialization.""" + manager: ThreadPoolManager = thread_pool_manager._pool_manager + assert manager.pool.maxThreadCount() == 4 + assert manager._timer.interval() == 100 + + # Test submitting tasks + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + assert len(manager.get_scheduled_tests()) == 2 + assert len(manager.get_active_tests()) == 0 + + # Clear queue + manager.clear_queue() + assert device_test_mock_1.cancel.call_count == 1 + assert device_test_mock_2.cancel.call_count == 1 + assert device_test_mock_1.signals.device_validated.disconnect.call_count == 1 + assert device_test_mock_2.signals.device_validated.disconnect.call_count == 1 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 0 + + def test_thread_pool_process_queue(self, thread_pool_manager, qtbot): + """Test ThreadPoolManager process queue logic.""" + # Submit 2 elements to the queue + manager: ThreadPoolManager = thread_pool_manager._pool_manager + device_test_mock_1 = mock.MagicMock() + device_test_mock_2 = mock.MagicMock() + manager.submit(device_name="test_device", device_test=device_test_mock_1) + manager.submit(device_name="test_device_2", device_test=device_test_mock_2) + + # Validations running cb + container = [] + + def _validations_running_cb(is_true: bool): + container.append(is_true) + + manager.validations_are_running.connect(_validations_running_cb) + with mock.patch.object(manager.pool, "start") as mock_pool_start: + with qtbot.waitSignal(manager.validations_are_running): + # Process queue, should start both tasks + manager._process_queue() + assert mock_pool_start.call_count == 2 + assert len(manager.get_scheduled_tests()) == 0 + assert len(manager.get_active_tests()) == 2 + assert len(container) == 1 + assert container[0] is True + device_test_mock_1.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + device_test_mock_2.signals.device_validated.connect.assert_called_with( + manager._on_task_finished + ) + + # Simulate one task finished + manager._on_task_finished({"name": "test_device"}, True, True, "All good") + assert len(manager.get_active_tests()) == 1 + + # Process queue again, nothing should happen as queue is empty + mock_pool_start.reset_mock() + manager._process_queue() + assert mock_pool_start.call_count == 0 + assert len(manager.get_active_tests()) == 1 + + @pytest.fixture + def legend_label(self, qtbot): + """Fixture to create a TestLegendLabel instance.""" + label = LegendLabel() + qtbot.addWidget(label) + qtbot.waitExposed(label) + yield label + + def test_legend_label(self, legend_label: LegendLabel): + """Test LegendLabel.""" + layout: QtWidgets.QGridLayout = legend_label.layout() + # Verify layout structure + assert layout.rowCount() == 2 + assert layout.columnCount() == 6 + # Assert labels and icons are present + label = layout.itemAtPosition(0, 0).widget() + assert label.text() == "Config Legend:" + label = layout.itemAtPosition(1, 0).widget() + assert label.text() == "Connect Legend:" + + @pytest.fixture + def ophyd_test(self, qtbot): + """Fixture to create an OphydValidation instance. We patch the method that starts the polling loop to avoid side effects.""" + with ( + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop", + return_value=None, + ), + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session", + return_value=False, + ), + ): + widget = OphydValidation() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_ophyd_test_initialization(self, ophyd_test: OphydValidation, qtbot): + """Test OphydValidation widget initialization.""" + assert isinstance(ophyd_test.list_widget, BECList) + assert isinstance(ophyd_test.thread_pool_manager, ThreadPoolManager) + layout = ophyd_test.layout() + # Widget with layout + legend label + assert isinstance(layout.itemAt(1).widget(), LegendLabel) + + # Test clicking the stop validation button + click_event = Event() + + def _stop_validation_button_clicked(): + click_event.set() + + ophyd_test._stop_validation_button.clicked.connect(_stop_validation_button_clicked) + with qtbot.waitSignal(ophyd_test._stop_validation_button.clicked): + # Simulate click + qtbot.mouseClick(ophyd_test._stop_validation_button, QtCore.Qt.LeftButton) + assert click_event.is_set() + + def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): + """Test adding devices to OphydValidation widget.""" + sample_devices = [ + { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", + "enabled": True, + "readOnly": False, + "softwareTrigger": False, + }, + { + "name": "detector_main", + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "async", + "onFailure": "buffer", + "deviceTags": ["detectors", "main"], + "description": "Main area detector", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ] + # Initially empty, add devices + with mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + + # Adding again should overwrite existing ones + with mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_configs: + ophyd_test.change_device_configs(sample_devices, added=True) + assert len(ophyd_test.get_device_configs()) == 2 + assert mock_remove_configs.call_count == 2 # Once for each device + + # Click item in list + item = ophyd_test.list_widget.item(0) + with qtbot.waitSignal(ophyd_test.item_clicked) as blocker: + qtbot.mouseClick( + ophyd_test.list_widget.viewport(), + QtCore.Qt.LeftButton, + pos=ophyd_test.list_widget.visualItemRect(item).center(), + ) + device_name = blocker.args[0] + assert ( + ophyd_test.list_widget.get_widget_for_item(item).device_model.device_name + == device_name + ) + + # Clear running validation + with ( + mock.patch.object( + ophyd_test.thread_pool_manager, "clear_device_in_queue" + ) as mock_clear, + mock.patch.object(ophyd_test, "_on_device_test_completed") as mock_on_completed, + ): + ophyd_test.cancel_validation("motor_x") + mock_clear.assert_called_once_with("motor_x") + mock_on_completed.assert_called_once_with( + sample_devices[0], + ConfigStatus.UNKNOWN.value, + ConnectionStatus.UNKNOWN.value, + "motor_x was cancelled by user.", + ) + + def test_ophyd_test_submit_test( + self, ophyd_test: OphydValidation, validation_list_item: ValidationListItem, qtbot + ): + """Test submitting a device test to the thread pool manager.""" + with ( + mock.patch.object( + validation_list_item, "validation_scheduled" + ) as mock_validation_scheduled, + mock.patch.object(ophyd_test.thread_pool_manager, "submit") as mock_thread_pool_submit, + ): + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_called_once() + mock_thread_pool_submit.assert_called_once() + + mock_validation_scheduled.reset_mock() + mock_thread_pool_submit.reset_mock() + # Assume device is already in Redis + with ( + mock.patch.object(ophyd_test, "_is_device_in_redis_session") as mock_in_redis, + mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_device, + ): + mock_in_redis.return_value = True + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + ophyd_test._submit_test( + validation_list_item, connect=True, force_connect=False, timeout=10 + ) + mock_validation_scheduled.assert_not_called() + mock_thread_pool_submit.assert_not_called() + assert validation_list_item.device_model.device_config == blocker.args[0] + assert blocker.args[1] is ConfigStatus.VALID.value + assert blocker.args[2] is ConnectionStatus.CONNECTED.value + + def test_ophyd_test_compare_device_configs(self, ophyd_test: OphydValidation): + """Test comparing device configurations.""" + device_config_1 = { + "name": "motor_x", + "deviceClass": "EpicsMotor", + "readoutPriority": "baseline", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", "enabled": True, "readOnly": False, + "softwareTrigger": False, + } + device_config_2 = device_config_1.copy() + # Should be equal + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is True + + # Change a field + device_config_2["description"] = "Modified description" + assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is False + + @pytest.mark.parametrize( + "config_status,connection_status, msg", + [ + (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value, "Validation successful"), + ( + ConfigStatus.INVALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + "Validation failed", + ), + ], + ) + def test_ophyd_test_validation_succeeds( + self, ophyd_test: OphydValidation, qtbot, config_status, connection_status, msg + ): + """Test handling of successful device validation.""" + sample_device = { + "name": "motor_x", + "deviceClass": "EpicsMotor", "readoutPriority": "baseline", - "deviceTags": ["tag1", "tag2"], - "description": "Test device Z", - }, - { - "name": "alpha_device", - "deviceClass": "TestClass2", - "enabled": False, - "readOnly": True, - "readoutPriority": "primary", - "deviceTags": ["tag3"], - "description": "Test device A", - }, - { - "name": "beta_device", - "deviceClass": "TestClass3", + "onFailure": "retry", + "deviceTags": ["motors"], + "description": "X-axis motor", "enabled": True, "readOnly": False, - "readoutPriority": "secondary", - "deviceTags": [], - "description": "Test device B", - }, - ] - - # Add devices to source model - device_table_model.add_device_configs(test_devices) - - # Check source model has 3 rows and proper columns - assert device_table_model.rowCount() == 3 - assert device_table_model.columnCount() == 10 - - # Check proxy model propagates the data - assert device_proxy_model.rowCount() == 3 - assert device_proxy_model.columnCount() == 10 - - # Verify data propagation through proxy - check names in original order - for i, expected_device in enumerate(test_devices): - proxy_index = device_proxy_model.index(i, 1) # Column 1 is name - source_index = device_proxy_model.mapToSource(proxy_index) - source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole) - assert source_data == expected_device["name"] - - # Check proxy data matches source - proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole) - assert proxy_data == source_data - - # Verify all columns are accessible - headers = device_table_model.headers - for col, header in enumerate(headers): - header_data = device_table_model.headerData( - col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole + "softwareTrigger": False, + } + with ( + mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test, + mock.patch.object(ophyd_test, "_is_device_in_redis_session", return_value=False), + ): + ophyd_test.change_device_configs([sample_device], added=True) + + # Emit validation completed signal from thread pool manager + with qtbot.waitSignal(ophyd_test.validation_completed) as blocker: + validation_item = ophyd_test.list_widget.get_widget_for_item( + ophyd_test.list_widget.item(0) + ) + with mock.patch.object( + validation_item, "on_validation_finished" + ) as mock_on_validation_finished: + ophyd_test.thread_pool_manager.device_validated.emit( + sample_device, config_status, connection_status, msg + ) + if config_status != ConfigStatus.VALID.value: + mock_on_validation_finished.assert_called_once_with( + validation_msg=msg, + config_status=config_status, + connection_status=connection_status, + ) + + assert blocker.args[0] == sample_device + assert blocker.args[1] == config_status + assert blocker.args[2] == connection_status + assert blocker.args[3] == msg + + +class TestDeviceConfigTemplate: + + def test_try_literal_eval(self): + """Test the _try_literal_eval static method.""" + # handle booleans + assert _try_literal_eval("True") is True + assert _try_literal_eval("False") is False + assert _try_literal_eval("true") is True + assert _try_literal_eval("false") is False + # handle empty string + assert _try_literal_eval("") == "" + # Lists + assert _try_literal_eval([0, 1, 2]) == [0, 1, 2] + # Set and tuples + assert _try_literal_eval((1, 2, 3)) == (1, 2, 3) + # Numbers int and float + assert _try_literal_eval("123") == 123 + assert _try_literal_eval("45.67") == 45.67 + # if literal eval fails, return original string + assert _try_literal_eval(" invalid text,,, ") == " invalid text,,, " + + def _create_widget_for_device_field(self, field_name: str, qtbot) -> QtWidgets.QWidget: + """Helper method to create a widget for a given device field.""" + field = DEVICE_FIELDS[field_name] + widget = field.widget_cls() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + def test_device_fields_name(self, qtbot): + """Test DEVICE_FIELDS content for 'name' field.""" + colors = get_accent_colors() + name_field: DeviceConfigField = DEVICE_FIELDS["name"] + assert name_field.label == "Name" + assert name_field.widget_cls == InputLineEdit + assert name_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("name", qtbot) + if name_field.validation_callback is not None: + for cb in name_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("valid_device_name") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("invalid _name") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_device_class(self, qtbot): + """Test DEVICE_FIELDS content for 'deviceClass' field.""" + colors = get_accent_colors() + device_class_field: DeviceConfigField = DEVICE_FIELDS["deviceClass"] + assert device_class_field.label == "Device Class" + assert device_class_field.widget_cls == InputLineEdit + assert device_class_field.required is True + # Create widget and test + widget: InputLineEdit = self._create_widget_for_device_field("deviceClass", qtbot) + if device_class_field.validation_callback is not None: + for cb in device_class_field.validation_callback: + widget.register_validation_callback(cb) + # Empty input is invalid + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + # Valid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("EpicsMotor") + assert widget.styleSheet() == "" + # InValid input + with qtbot.waitSignal(widget.textChanged): + widget.setText("wrlong-sadnjkas:'&") + assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + def test_device_fields_description(self, qtbot): + """Test DEVICE_FIELDS content for 'description' field.""" + description_field: DeviceConfigField = DEVICE_FIELDS["description"] + assert description_field.label == "Description" + assert description_field.widget_cls == QtWidgets.QTextEdit + assert description_field.required is False + assert description_field.placeholder_text == "Short device description" + # Create widget and test + widget: QtWidgets.QTextEdit = self._create_widget_for_device_field("description", qtbot) + + def test_device_fields_toggle_fields(self, qtbot): + """Test DEVICE_FIELDS content for 'enabled' and 'readOnly' fields.""" + for field_name in ["enabled", "readOnly", "softwareTrigger"]: + field: DeviceConfigField = DEVICE_FIELDS[field_name] + assert field.label in ["Enabled", "Read Only", "Software Trigger"] + assert field.widget_cls == ToggleSwitch + assert field.required is False + if field_name == "enabled": + assert field.default is True + else: + assert field.default is False + + @pytest.fixture + def device_config_template(self, qtbot): + """Fixture to create a DeviceConfigTemplate instance.""" + template = DeviceConfigTemplate() + qtbot.addWidget(template) + qtbot.waitExposed(template) + yield template + + def test_device_config_teamplate_default_init( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test DeviceConfigTemplate default initialization.""" + assert ( + device_config_template.template + == OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"] ) - assert header_data is not None - - -def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model): - """Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort.""" - # Use same test data as above - zebra, alpha, beta (not alphabetically sorted) - test_devices = [ - { - "status": ValidationStatus.VALID, - "name": "zebra_device", - "deviceClass": "TestClass1", - "enabled": True, - }, - { - "status": ValidationStatus.PENDING, - "name": "alpha_device", - "deviceClass": "TestClass2", - "enabled": False, - }, - { - "status": ValidationStatus.FAILED, - "name": "beta_device", - "deviceClass": "TestClass3", - "enabled": True, - }, - ] - - device_table_model.add_device_configs(test_devices) - - # Verify initial order (unsorted) - assert ( - device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) - == "zebra_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) - == "alpha_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) - == "beta_device" - ) - - # Enable sorting and sort by name column (column 1) - mock_table_view.setSortingEnabled(True) - # header = mock_table_view.horizontalHeader() - # qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton) - device_proxy_model.sort(1, QtCore.Qt.AscendingOrder) - - # After sorting, verify alphabetical order: alpha, beta, zebra - assert ( - device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) - == "alpha_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) - == "beta_device" - ) - assert ( - device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) - == "zebra_device" - ) - -def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model): - """Test (C): Remove rows from BECTableView and verify propagation.""" - # Set up test data - test_devices = [ - {"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True}, - {"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False}, - {"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True}, - ] - - device_table_model.add_device_configs(test_devices) - assert device_table_model.rowCount() == 3 - assert device_proxy_model.rowCount() == 3 - - # Mock the confirmation dialog to first cancel, then confirm - with mock.patch.object( - mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True] - ) as mock_confirm: - - # Create mock selection for middle device (device_to_remove at row 1) - selection_model = mock.MagicMock() - proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column - selection_model.selectedRows.return_value = [proxy_index_to_remove] - - mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model) - - # Verify the device we're about to remove - device_name_to_remove = device_proxy_model.data( - device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole + # Check settings box, should have 3 labels, 2 InputLineEdit, 1 QTextEdit + assert len(device_config_template.settings_box.findChildren(QtWidgets.QLabel)) == 3 + assert len(device_config_template.settings_box.findChildren(InputLineEdit)) == 2 + assert len(device_config_template.settings_box.findChildren(QtWidgets.QTextEdit)) == 1 + + # Check advanced control box, should have 5 labels for + # readoutPriority, onFailure, enabled, readOnly, softwareTrigger + assert len(device_config_template.advanced_control_box.findChildren(QtWidgets.QLabel)) == 5 + assert len(device_config_template.advanced_control_box.findChildren(ToggleSwitch)) == 3 + assert ( + len(device_config_template.advanced_control_box.findChildren(ReadoutPriorityComboBox)) + == 1 ) - assert device_name_to_remove == "device_to_remove" - - # Call delete_selected method - mock_table_view.delete_selected() - - # Verify confirmation was called - mock_confirm.assert_called_once() - - assert device_table_model.rowCount() == 3 # No change on first call - assert device_proxy_model.rowCount() == 3 - - # Call delete_selected again, this time it should confirm - mock_table_view.delete_selected() - - # Check that the device was removed from source model - assert device_table_model.rowCount() == 2 - assert device_proxy_model.rowCount() == 2 - - # Verify the remaining devices are correct - remaining_names = [] - for i in range(device_proxy_model.rowCount()): - name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole) - remaining_names.append(name) - - assert "device_to_remove" not in remaining_names - - -def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model): - """Test DeviceFilterProxyModel text filtering functionality.""" - # Set up test data with different device names and classes - test_devices = [ - {"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"}, - {"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"}, - {"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"}, - ] - - device_table_model.add_device_configs(test_devices) - assert device_proxy_model.rowCount() == 3 - - # Test filtering by name - device_proxy_model.setFilterText("motor") - assert device_proxy_model.rowCount() == 2 - # Should show 2 rows (motor_x and motor_y) - visible_count = 0 - for i in range(device_proxy_model.rowCount()): - if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): - continue - visible_count += 1 - - # Test filtering by device class - device_proxy_model.setFilterText("EpicsDetector") - # Should show 1 row (detector_main) - detector_visible = False - assert device_proxy_model.rowCount() == 1 - for i in range(device_table_model.rowCount()): - if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): - source_index = device_table_model.index(i, 1) # Name column - name = device_table_model.data(source_index, QtCore.Qt.DisplayRole) - if name == "detector_main": - detector_visible = True - break - assert detector_visible - - # Clear filter - device_proxy_model.setFilterText("") - assert device_proxy_model.rowCount() == 3 - # Should show all 3 rows again - all_visible = all( - device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()) - for i in range(device_table_model.rowCount()) - ) - assert all_visible - - -### -# Test DeviceTableView -### - - -@pytest.fixture -def device_table_view(qtbot): - """Fixture to create a DeviceTableView instance.""" - view = DeviceTableView() - qtbot.addWidget(view) - qtbot.waitExposed(view) - yield view - - -def test_device_table_view_initialization(qtbot, device_table_view): - """Test the DeviceTableView search method.""" - - # Check that the search input fields are properly initialized and connected - qtbot.keyClicks(device_table_view.search_input, "zebra") - qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000) - qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton) - qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000) - - # Check table setup - - # header - header = device_table_view.table.horizontalHeader() - assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags - assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description - - # table selection - assert ( - device_table_view.table.selectionBehavior() - == QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows - ) - assert ( - device_table_view.table.selectionMode() - == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection - ) + assert len(device_config_template.advanced_control_box.findChildren(OnFailureComboBox)) == 1 + # Check connection box for CustomDevice, should be empty dict. + assert isinstance( + device_config_template.connection_settings_box.layout().itemAt(0).widget(), + ParameterValueWidget, + ) -def test_device_table_theme_update(device_table_view): - """Test DeviceTableView apply_theme method.""" - # Check apply theme propagates - with ( - mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply, - mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated, + # Check additional settings box for CustomDevice, should be empty dict. + tool_box = device_config_template.additional_settings_box.layout().itemAt(0).widget() + assert isinstance(tool_box, QtWidgets.QToolBox) + assert isinstance(device_config_template._widgets["userParameter"], ParameterValueWidget) + assert isinstance(device_config_template._widgets["deviceTags"], DeviceTagsWidget) + + # Check default values and proper widgets in _widgets dict + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + assert isinstance(widget, ParameterValueWidget) + assert widget.parameters() == {} # Default empty dict for CustomDevice template + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_epics_motor( + self, device_config_template: DeviceConfigTemplate, qtbot + ): + """Test the DeviceConfigTemplate for the EpicsMotor device class.""" + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + + # Check that all widgets are created properly + for field_name, widget in device_config_template._widgets.items(): + if field_name == "deviceConfig": + for sub_field, sub_widget in widget.items(): + if sub_field in DEVICE_CONFIG_FIELDS: + field = DEVICE_CONFIG_FIELDS[sub_field] + assert isinstance(sub_widget, field.widget_cls) + if sub_field == "limits": + # Limits is LimitInputWidget + sub_widget: LimitInputWidget + assert sub_widget.get_limits() == [0, 0] # Default limits + else: + assert isinstance(widget, InputLineEdit) + continue + assert field_name in DEVICE_FIELDS + field = DEVICE_FIELDS[field_name] + assert isinstance(widget, field.widget_cls) + # Check default values + if field.default is not None: + if isinstance(widget, InputLineEdit): + assert widget.text() == str(field.default) + elif isinstance(widget, ToggleSwitch): + assert widget.isChecked() == field.default + elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)): + assert widget.currentText() == field.default + + def test_device_config_template_get_set_config( + self, device_config_template: DeviceConfigTemplate, qtbot ): - device_table_view.apply_theme("dark") - mock_apply.assert_called_once_with("dark") - mock_validated.assert_called_once_with("dark") - - -def test_device_table_view_updates(device_table_view): - """Test DeviceTableView methods that update the view and model.""" - # Test theme update triggered.. - - cfgs = [ - {"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True}, - {"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False}, - {"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True}, - ] - with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize: - # Should be called once for rowsInserted - device_table_view.set_device_config(cfgs) - assert device_table_view.get_device_config() == cfgs - mock_autosize.assert_called_once() - # Update validation status, should be called again - device_table_view.update_device_validation("test_device", ValidationStatus.VALID) - assert mock_autosize.call_count == 2 - # Remove a device, should triggere also a _request_autosize_columns call - device_table_view.remove_device_configs([cfgs[0]]) - assert device_table_view.get_device_config() == cfgs[1:] - assert mock_autosize.call_count == 3 - # Remove one device manually - device_table_view.remove_device("another_device") # Should remove the last device - assert device_table_view.get_device_config() == cfgs[2:] - assert mock_autosize.call_count == 4 - # Reset the model should call it once again - device_table_view.clear_device_configs() - assert mock_autosize.call_count == 5 - assert device_table_view.get_device_config() == [] - - -def test_device_table_view_get_help_md(device_table_view): - """Test DeviceTableView get_help_md method.""" - with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at: - mock_index_at.isValid = mock.MagicMock(return_value=True) - with mock.patch.object(device_table_view, "_model") as mock_model: - mock_model.headerData = mock.MagicMock(side_effect=["softTrig"]) - # Second call is True, should return the corresponding help md - assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"] + # Test get config for default Custom Device template + device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"]) + config = device_config_template.get_config_fields() + for k, v in OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"].items(): + if k == "deviceConfig": + v: EpicsMotorDeviceConfigTemplate + v.model_validate(config["deviceConfig"]) + continue + if isinstance(v, (list, tuple)): + v = tuple(v) + config_value = config[k] + if isinstance(config_value, (list, tuple)): + config_value = tuple(config_value) + assert config_value == v + + # Set config from Model for custom EpicsMotor + model = DeviceModel( + name="motor_x", + deviceClass="ophyd.EpicsMotor", + readoutPriority="baseline", + enabled=False, + deviceConfig={"prefix": "MOTOR_X:", "limits": [-10, 10], "additional_field": 42}, + deviceTags=["motors", "x_axis"], + userParameter={"param1": 100, "param2": "value2"}, + ) + device_config_template.set_config_fields(model.model_dump()) + # Check config + config = device_config_template.get_config_fields() + assert config["name"] == "motor_x" + assert config["deviceClass"] == "ophyd.EpicsMotor" + assert config["readoutPriority"] == "baseline" + assert config["enabled"] is False + assert config["deviceConfig"] == { + "prefix": "MOTOR_X:", + "limits": [-10, 10], + "additional_field": 42, + } + assert set(config["deviceTags"]) == {"motors", "x_axis"} + assert config["userParameter"] == {"param1": 100, "param2": "value2"} + + def test_limit_input_widget(self, qtbot): + """Test LimitInputWidget functionality.""" + colors = get_accent_colors() + widget = LimitInputWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Default limits should be [0, 0] + assert widget.get_limits() == [0, 0] + + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Set limits externally + widget.set_limits([-5, 5]) + assert widget.get_limits() == [-5, 5] + assert widget._is_valid_limit() is True + assert widget.enable_toggle.isChecked() is False + + # Enable toggle + with qtbot.waitSignal(widget.enable_toggle.stateChanged): + widget.enable_toggle.setChecked(True) + + assert widget.enable_toggle.isChecked() is True + # Set invalid limits (min >= max) + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(2) + widget.max_input.setValue(1) + + assert widget._is_valid_limit() is False + assert widget.min_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + assert widget.max_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};" + + # Reset to default values + with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]): + widget.min_input.setValue(0) + widget.max_input.setValue(0) + + assert widget.get_limits() == [0, 0] + assert widget.min_input.styleSheet() == "" + assert widget.max_input.styleSheet() == "" + + def test_parameter_value_widget(self, qtbot): + """Test ParameterValueWidget functionality.""" + widget = ParameterValueWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no parameters + assert widget.parameters() == {} + + # Add parameters + sample_params = {"param1": 10, "param2": "value", "param3": True} + for k, v in sample_params.items(): + widget.add_parameter_line(k, v) + assert widget.parameters() == sample_params + + # Modify a parameter + param1_widget: InputLineEdit = widget.tree_widget.itemWidget( + widget.tree_widget.topLevelItem(0), 1 + ) + with qtbot.waitSignal(param1_widget.textChanged): + param1_widget.setText("20") + updated_params = widget.parameters() + assert updated_params["param1"] == 20 + assert updated_params["param2"] == "value" + assert updated_params["param3"] is True + # Select top item + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + widget.remove_parameter_line() + # Check that param1 is removed + assert widget.parameters() == {"param2": "value", "param3": True} + # Clear all parameters + widget.clear_widget() + assert widget.parameters() == {} + + def test_device_tags_widget(self, qtbot): + """Test DeviceTagsWidget functionality.""" + widget = DeviceTagsWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + # Initially no tags + assert widget.parameters() == [] + + # Add tags + with qtbot.waitSignal(widget._button_add.clicked): + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 2 + assert widget.parameters() == [] # No value yet means no parameters + + # set tag text + widget_item = widget.tree_widget.topLevelItem(0) + tag_widget: InputLineEdit = widget.tree_widget.itemWidget(widget_item, 0) + with qtbot.waitSignal(tag_widget.textChanged): + tag_widget.setText("motor") + assert widget.parameters() == ["motor"] + + # Remove tag + widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0)) + with qtbot.waitSignal(widget._button_remove.clicked): + qtbot.mouseClick(widget._button_remove, QtCore.Qt.LeftButton) + qtbot.wait(200) # wait item to be added to tree widget + + assert widget.tree_widget.topLevelItemCount() == 1 + + # Clear all tags + widget.clear_widget() + assert widget.tree_widget.topLevelItemCount() == 0 diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index a85be7319..220fa8cc0 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -5,220 +5,657 @@ from unittest import mock import pytest -from qtpy import QtCore -from qtpy.QtWidgets import QFileDialog, QMessageBox +from bec_lib.atlas_models import Device as DeviceModel +from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES +from qtpy import QtCore, QtWidgets -from bec_widgets.applications.views.device_manager_view.device_manager_view import ( +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import ( ConfigChoiceDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import ( + DeviceFormDialog, + DeviceManagerOphydValidationDialog, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( + DeviceStatusItem, + UploadRedisDialog, + ValidationSection, +) +from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + DeviceManagerDisplayWidget, +) +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( DeviceManagerView, + DeviceManagerWidget, ) -from bec_widgets.utils.help_inspector.help_inspector import HelpInspector from bec_widgets.widgets.control.device_manager.components import ( - DeviceTableView, + DeviceTable, DMConfigView, - DMOphydTest, DocstringView, + OphydValidation, +) +from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import ( + ConfigStatus, + ConnectionStatus, + OphydValidation, ) @pytest.fixture -def dm_view(qtbot): - """Fixture for DeviceManagerView.""" - widget = DeviceManagerView() - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -@pytest.fixture -def config_choice_dialog(qtbot, dm_view): - """Fixture for ConfigChoiceDialog.""" - dialog = ConfigChoiceDialog(dm_view) - qtbot.addWidget(dialog) - qtbot.waitExposed(dialog) - yield dialog - - -def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog): - """Test the configuration choice dialog.""" - assert config_choice_dialog is not None - assert config_choice_dialog.parent() == dm_view - - # Test dialog components - with ( - mock.patch.object(config_choice_dialog, "accept") as mock_accept, - mock.patch.object(config_choice_dialog, "reject") as mock_reject, +def device_config() -> dict: + """Fixture for a sample device configuration.""" + return DeviceModel( + name="TestDevice", enabled=True, deviceClass="TestClass", readoutPriority="baseline" + ).model_dump() + + +class TestDeviceManagerViewDialogs: + """Test class for DeviceManagerView dialog interactions.""" + + @pytest.fixture + def mock_dm_view(self, qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerDisplayWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def config_choice_dialog(self, qtbot, mock_dm_view): + """Fixture for ConfigChoiceDialog.""" + try: + dialog = ConfigChoiceDialog(mock_dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_config_choice_dialog(self, mock_dm_view, config_choice_dialog, qtbot): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == mock_dm_view + + # Test dialog components + with (mock.patch.object(config_choice_dialog, "done") as mock_done,): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.REPLACE) + mock_done.reset_mock() + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.ADD) + mock_done.reset_mock() + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_done.assert_called_once_with(config_choice_dialog.Result.CANCEL) + + @pytest.fixture + def device_manager_ophyd_test_dialog(self, qtbot): + """Fixture for DeviceManagerOphydValidationDialog.""" + dialog = DeviceManagerOphydValidationDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_manager_ophyd_test_dialog( + self, device_manager_ophyd_test_dialog: DeviceManagerOphydValidationDialog, qtbot ): - - # Replace - qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) - mock_accept.assert_called_once() - mock_reject.assert_not_called() - mock_accept.reset_mock() - assert config_choice_dialog.result() == config_choice_dialog.REPLACE - # Add - qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) - mock_accept.assert_called_once() - mock_reject.assert_not_called() - mock_accept.reset_mock() - assert config_choice_dialog.result() == config_choice_dialog.ADD - # Cancel - qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) - mock_accept.assert_not_called() - mock_reject.assert_called_once() - assert config_choice_dialog.result() == config_choice_dialog.CANCEL - - -class TestDeviceManagerViewInitialization: - """Test class for DeviceManagerView initialization and basic components.""" - - def test_dock_manager_initialization(self, dm_view): - """Test that the QtAds DockManager is properly initialized.""" - assert dm_view.dock_manager is not None - assert dm_view.dock_manager.centralWidget() is not None - - def test_central_widget_is_device_table_view(self, dm_view): - """Test that the central widget is DeviceTableView.""" - central_widget = dm_view.dock_manager.centralWidget().widget() - assert isinstance(central_widget, DeviceTableView) - assert central_widget is dm_view.device_table_view - - def test_dock_widgets_exist(self, dm_view): + """Test the DeviceManagerOphydValidationDialog.""" + dialog = device_manager_ophyd_test_dialog + assert dialog.text_box.toPlainText() == "" + + dialog._on_device_validated( + {"name": "TestDevice", "enabled": True}, + config_status=0, + connection_status=0, + validation_msg="All good", + ) + assert dialog.validation_result == ( + {"name": "TestDevice", "enabled": True}, + 0, + 0, + "All good", + ) + assert dialog.text_box.toPlainText() != "" + + @pytest.fixture + def device_form_dialog(self, qtbot): + """Fixture for DeviceFormDialog.""" + dialog = DeviceFormDialog() + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_device_form_dialog(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test the DeviceFormDialog.""" + # Initial state + dialog = device_form_dialog + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES) + + # Test select a group from available templates + variant_combo = dialog._control_widgets["variant_combo"] + assert variant_combo.isEnabled() is False + + with qtbot.waitSignal(group_combo.currentTextChanged): + epics_signal_index = group_combo.findText("EpicsSignal") + group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group + + assert variant_combo.count() == len(OPHYD_DEVICE_TEMPLATES["EpicsSignal"]) + assert variant_combo.isEnabled() is True + + # Check that numb of widgets in connection settings box is correct + fields_in_config = len( + OPHYD_DEVICE_TEMPLATES["EpicsSignal"].get(variant_combo.currentText(), {}) + ) # At this point this should be read_pv & write_pv + connection_settings_layout: QtWidgets.QGridLayout = ( + dialog._device_config_template.connection_settings_box.layout() + ) + assert ( + connection_settings_layout.count() == fields_in_config * 2 + ) # Each field has a label and a widget + + def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): + """Test setting device configuration in DeviceFormDialog.""" + dialog = device_form_dialog + sample_config = { + "name": "TestDevice", + "enabled": True, + "deviceClass": "ophyd.EpicsSignal", + "readoutPriority": "baseline", + "deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"}, + } + DeviceModel.model_validate(sample_config) + dialog.set_device_config(sample_config) + + group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] + assert group_combo.currentText() == "EpicsSignal" + variant_combo: QtWidgets.QComboBox = dialog._control_widgets["variant_combo"] + assert variant_combo.currentText() == "EpicsSignal" + config = dialog._device_config_template.get_config_fields() + assert config["name"] == "TestDevice" + assert config["deviceClass"] == "ophyd.EpicsSignal" + assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" + # Set the validation results, assume that test was running + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.VALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + with qtbot.waitSignal(dialog.accepted_data) as sig_blocker: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + config, _, _, _, _ = sig_blocker.args + mock_warning_box.assert_not_called() + + # Called with config_status invalid should show warning + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.INVALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once() + + # Set to random config without name + + random_config = {"deviceClass": "Unknown"} + dialog.set_device_config(random_config) + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + 0, + 0, + "", + ) + assert group_combo.currentText() == "CustomDevice" + assert variant_combo.currentText() == "CustomDevice" + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once_with( + "Invalid Device Name", + f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + ) + + def test_device_status_item(self, device_config: dict, qtbot): + """Test the DeviceStatusItem widget.""" + item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0) + qtbot.addWidget(item) + qtbot.waitExposed(item) + assert item.device_config == device_config + assert item.device_name == device_config.get("name", "") + assert item.config_status == 0 + assert item.connection_status == 0 + assert "config_status" in item.icons + assert "connection_status" in item.icons + + # Update status + item.update_status(config_status=1, connection_status=2) + assert item.config_status == 1 + assert item.connection_status == 2 + + def test_validation_section(self, device_config: dict, qtbot): + """Test the validation section.""" + device_config_2 = device_config.copy() + device_config_2["name"] = "device_2" + + # Create section + section = ValidationSection(title="Validation Results") + qtbot.addWidget(section) + qtbot.waitExposed(section) + assert section.title() == "Validation Results" + initial_widget_in_container = section.table.rowCount() + + # Add widgets + section.add_device(device_config=device_config, config_status=0, connection_status=0) + assert initial_widget_in_container + 1 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config["name"]) == section.table.rowCount() - 1 + + # Add another device + section.add_device(device_config=device_config_2, config_status=1, connection_status=1) + assert initial_widget_in_container + 2 == section.table.rowCount() + # Should be the first index, so rowCount - 1 + assert section._find_row_by_name(device_config_2["name"]) == section.table.rowCount() - 1 + + # Clear devices + section.clear_devices() + assert section.table.rowCount() == 0 + + # Update test summary label + section.update_summary("2 devices validated, 1 failed.") + assert section.summary_label.text() == "2 devices validated, 1 failed." + + @pytest.fixture + def device_configs_valid(self, device_config: dict): + """Fixture for multiple device configurations.""" + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.VALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_invalid(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.INVALID.value, i) + return return_dict + + @pytest.fixture + def device_configs_unknown(self, device_config: dict): + return_dict = {} + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + return_dict[name] = (dev_config_copy, ConfigStatus.UNKNOWN.value, i) + return return_dict + + @pytest.fixture + def upload_redis_dialog(self, qtbot): + """Fixture for UploadRedisDialog.""" + dialog = UploadRedisDialog( + parent=None, ophyd_test_widget=mock.MagicMock(spec=OphydValidation), device_configs={} + ) + try: + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + finally: + dialog.close() + + def test_upload_redis_valid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_valid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_valid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_unknown_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_unknown, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_unknown + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_invalid_config( + self, upload_redis_dialog: UploadRedisDialog, device_configs_invalid, qtbot + ): + """ + Test the UploadRedisDialog with a valid device configuration. + """ + dialog = upload_redis_dialog + configs = device_configs_invalid + dialog.set_device_config(configs) + + n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value]) + n_untested = len( + [True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value] + ) + n_has_cannot_connect = len( + [ + True + for _, cs, conn in configs.values() + if conn == ConnectionStatus.CANNOT_CONNECT.value + ] + ) + + # Check the initial states + assert dialog.has_invalid_configs == n_invalid + assert dialog.has_untested_connections == n_untested + assert dialog.has_cannot_connect == n_has_cannot_connect + + num_devices = len(configs) + expected_text = "" + if n_invalid > 0: + expected_text = f"{n_invalid} of {num_devices} device configurations are invalid." + else: + expected_text = f"All {num_devices} device configurations are valid." + if n_untested > 0: + expected_text += f"{n_untested} device connections are not tested." + if n_has_cannot_connect > 0: + expected_text += f"{n_has_cannot_connect} device connections cannot be established." + + assert dialog.config_section.summary_label.text() == expected_text + + def test_upload_redis_validate_connections(self, device_configs_invalid, qtbot): + """Test the validate connections method in UploadRedisDialog.""" + configs = device_configs_invalid + ophyd_test_mock = mock.MagicMock(spec=OphydValidation) + try: + dialog = UploadRedisDialog( + parent=None, ophyd_test_widget=ophyd_test_mock, device_configs=configs + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + + with mock.patch.object( + dialog.ophyd_test_widget, "change_device_configs" + ) as mock_change: + dialog._validate_connections() + mock_change.assert_called_once_with( + [cfg for k, (cfg, _, _) in configs.items() if k in ["Device_0", "Device_3"]], + added=True, + connect=True, + ) + finally: + dialog.close() + + +class TestDeviceManagerView: + """Test class for DeviceManagerView functionality.""" + + @pytest.fixture + def dm_view(self, qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_dm_view_initialization(self, dm_view, qtbot): + """Test DeviceManagerView initialization.""" + assert isinstance(dm_view.device_manager_widget, DeviceManagerWidget) + # If on_enter is called, overlay should be shown initially + dm_widget = dm_view.device_manager_widget + dm_view.on_enter() + assert dm_widget.stacked_layout.currentWidget() == dm_widget._overlay_widget + + with mock.patch.object(dm_widget.device_manager_display, "_load_file_action") as mock_load: + # Simulate clicking "Load Config From File" button + with qtbot.waitSignal(dm_widget.button_load_config_from_file.clicked): + qtbot.mouseClick(dm_widget.button_load_config_from_file, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + + # Reset for test loading current config + dm_widget._initialized = False + dm_widget.stacked_layout.setCurrentWidget(dm_widget._overlay_widget) + dm_widget.client.device_manager = mock.MagicMock() + + with mock.patch.object( + dm_widget.client.device_manager, "_get_redis_device_config" + ) as mock_get: + mock_get.return_value = [] + # Simulate clicking "Load Current Config" button + with mock.patch.object( + dm_widget.device_manager_display.device_table_view, "set_device_config" + ) as mock_set: + with qtbot.waitSignal(dm_widget.button_load_current_config.clicked): + qtbot.mouseClick(dm_widget.button_load_current_config, QtCore.Qt.LeftButton) + assert dm_widget._initialized is True + assert ( + dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display + ) + mock_set.assert_called_once_with([]) + + @pytest.fixture + def device_manager_display_widget(self, qtbot): + """Fixture for DeviceManagerDisplayWidget within DeviceManagerView.""" + widget = DeviceManagerDisplayWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + @pytest.fixture + def device_configs(self, device_config: dict): + """Fixture for multiple device configurations.""" + cfg_iter = [] + for i in range(4): + name = f"Device_{i}" + dev_config_copy = device_config.copy() + dev_config_copy["name"] = name + cfg_iter.append(dev_config_copy) + return cfg_iter + + def test_device_manager_view_add_remove_device( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config + ): + """Test adding a device via the DeviceManagerView.""" + dm_view = device_manager_display_widget + dm_view._add_to_table_from_dialog( + device_config, config_status=0, connection_status=0, msg="" + ) + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [device_config] + + # Remove the device + dm_view.device_table_view.table.selectRow(0) + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + table_config_list = dm_view.device_table_view.get_device_config() + assert table_config_list == [] + + def test_dock_widgets_exist(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that all required dock widgets are created.""" + dm_view = device_manager_display_widget dock_widgets = dm_view.dock_manager.dockWidgets() # Check that we have the expected number of dock widgets - assert len(dock_widgets) >= 4 + assert len(dock_widgets) == 4 # Check for specific widget types widget_types = [dock.widget().__class__ for dock in dock_widgets] + # OphydValidation is used in a layout with a QWidget assert DMConfigView in widget_types - assert DMOphydTest in widget_types assert DocstringView in widget_types + assert DeviceTable in widget_types - def test_toolbar_initialization(self, dm_view): + def test_toolbar_initialization( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test that the toolbar is properly initialized with expected bundles.""" + dm_view = device_manager_display_widget assert dm_view.toolbar is not None assert "IO" in dm_view.toolbar.bundles assert "Table" in dm_view.toolbar.bundles - def test_toolbar_components_exist(self, dm_view): - """Test that all expected toolbar components exist.""" - expected_components = [ - "load", - "save_to_disk", - "load_redis", - "update_config_redis", - "reset_composed", - "add_device", - "remove_device", - "rerun_validation", - ] - - for component in expected_components: - assert dm_view.toolbar.components.exists(component) - - def test_signal_connections(self, dm_view): - """Test that signals are properly connected between components.""" - # Test that device_table_view signals are connected - assert dm_view.device_table_view.selected_devices is not None - assert dm_view.device_table_view.device_configs_changed is not None - - # Test that ophyd_test_view signals are connected - assert dm_view.ophyd_test_view.device_validated is not None - - -class TestDeviceManagerViewIOBundle: - """Test class for DeviceManagerView IO bundle actions.""" - - def test_io_bundle_exists(self, dm_view): + def test_io_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that IO bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget assert "IO" in dm_view.toolbar.bundles - io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"] + io_actions = ["load", "save_to_disk", "flush_redis", "load_redis", "update_config_redis"] for action in io_actions: assert dm_view.toolbar.components.exists(action) - def test_load_file_action_triggered(self, tmp_path, dm_view): + def test_load_file_action_triggered( + self, tmp_path, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test load file action trigger mechanism.""" - + dm_view = device_manager_display_widget with ( - mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), - mock.patch( - "bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load" - ) as mock_yaml_load, - mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog, + mock.patch.object(dm_view, "_get_config_base_path", return_value=tmp_path), + mock.patch.object( + dm_view, "_get_file_path", return_value=str(tmp_path) + ) as mock_get_file, + mock.patch.object(dm_view, "_load_config_from_file") as mock_load_config, ): - mock_yaml_data = {"device1": {"param1": "value1"}} - mock_yaml_load.return_value = mock_yaml_data - # Setup dialog mock dm_view.toolbar.components._components["load"].action.action.triggered.emit() - mock_yaml_load.assert_called_once_with(tmp_path) - mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}]) - - def test_save_config_to_file(self, tmp_path, dm_view): - """Test saving config to file.""" - yaml_path = tmp_path / "test_save.yaml" - mock_config = [{"name": "device1", "param1": "value1"}] - with ( - mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), - mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path), - mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path), - mock.patch.object( - dm_view.device_table_view, "get_device_config", return_value=mock_config - ), - ): - dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() - assert yaml_path.exists() - + mock_get_file.assert_called_once_with(str(tmp_path), "open_file") + mock_load_config.assert_called_once_with(str(tmp_path)) -class TestDeviceManagerViewTableBundle: - """Test class for DeviceManagerView Table bundle actions.""" - - def test_table_bundle_exists(self, dm_view): + def test_table_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget): """Test that Table bundle exists and contains expected actions.""" + dm_view = device_manager_display_widget assert "Table" in dm_view.toolbar.bundles table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] for action in table_actions: assert dm_view.toolbar.components.exists(action) @mock.patch( - "bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question" + "bec_widgets.applications.views.device_manager_view.device_manager_display_widget._yes_no_question" ) - def test_reset_composed_view(self, mock_question, dm_view): + def test_reset_composed_view( + self, mock_question, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test reset composed view when user confirms.""" + dm_view = device_manager_display_widget with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: - mock_question.return_value = QMessageBox.StandardButton.Yes + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.Yes dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() mock_clear.assert_called_once() mock_clear.reset_mock() - mock_question.return_value = QMessageBox.StandardButton.No + mock_question.return_value = QtWidgets.QMessageBox.StandardButton.No dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() mock_clear.assert_not_called() - def test_add_device_action_connected(self, dm_view): + def test_add_device_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget + ): """Test add device action opens dialog correctly.""" + dm_view = device_manager_display_widget with mock.patch.object(dm_view, "_add_device_action") as mock_add: dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() mock_add.assert_called_once() - def test_remove_device_action(self, dm_view): - """Test remove device action.""" - with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove: - dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() - mock_remove.assert_called_once() + def test_run_validate_connection_action_connected( + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict + ): + """Test run validate connection action is connected.""" + dm_view = device_manager_display_widget + + with mock.patch.object( + dm_view.ophyd_test_view, "change_device_configs" + ) as mock_change_configs: + # First, add device configs to the table + dm_view.device_table_view.add_device_configs(device_configs) + assert mock_change_configs.call_args[0][1] is True # Configs were added + mock_change_configs.reset_mock() + + # Trigger the validate connection action without selection, should validate all + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + assert len(mock_change_configs.call_args[0][0]) == len(device_configs) + assert mock_change_configs.call_args[0][1:] == (True, True) # Configs were not added + mock_change_configs.reset_mock() - def test_rerun_device_validation(self, dm_view): - """Test rerun device validation action.""" - cfgs = [{"name": "device1", "param1": "value1"}] - with ( - mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change, - mock.patch.object( - dm_view.device_table_view.table, "selected_configs", return_value=cfgs - ), - ): + # Select a single row and trigger again, should only validate that one + dm_view.device_table_view.table.selectRow(0) dm_view.toolbar.components._components[ "rerun_validation" ].action.action.triggered.emit() - mock_change.assert_called_once_with(cfgs, True, True) + assert len(mock_change_configs.call_args[0][0]) == 1 diff --git a/tests/unit_tests/test_utils_bec_list.py b/tests/unit_tests/test_utils_bec_list.py new file mode 100644 index 000000000..17fcd30f0 --- /dev/null +++ b/tests/unit_tests/test_utils_bec_list.py @@ -0,0 +1,128 @@ +"""Tests for the BECList widget.""" + +from unittest.mock import MagicMock + +import pytest +from qtpy import QtWidgets + +from bec_widgets.utils.bec_list import BECList + + +@pytest.fixture +def bec_list(qtbot): + widget = BECList() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def sample_widget(qtbot): + widget = QtWidgets.QLabel("sample") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + +class TestBECList: + def test_add_widget_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key1", sample_widget) + + assert "key1" in bec_list + assert bec_list.count() == 1 + retrieved_widget = bec_list.get_widget("key1") + assert retrieved_widget == sample_widget + retrieved_item = bec_list.get_item("key1") + assert retrieved_item is not None + assert bec_list.itemWidget(retrieved_item) == sample_widget + + def test_add_widget_item_replaces_existing(self, bec_list, sample_widget, qtbot): + bec_list.add_widget_item("key", sample_widget) + replacement = QtWidgets.QLabel("replacement") + qtbot.addWidget(replacement) + qtbot.waitExposed(replacement) + + bec_list.add_widget_item("key", replacement) + + assert bec_list.count() == 1 + assert bec_list.get_widget("key") == replacement + # ensure first widget no longer tracked + assert sample_widget not in bec_list.get_widgets() + + def test_remove_widget_item(self, bec_list, sample_widget, monkeypatch): + bec_list.add_widget_item("key", sample_widget) + + close_mock = MagicMock() + delete_mock = MagicMock() + monkeypatch.setattr(sample_widget, "close", close_mock) + monkeypatch.setattr(sample_widget, "deleteLater", delete_mock) + + bec_list.remove_widget_item("key") + + assert bec_list.count() == 0 + assert "key" not in bec_list + close_mock.assert_called_once() + delete_mock.assert_called_once() + + def test_remove_widget_item_missing_key(self, bec_list): + bec_list.remove_widget_item("missing") + assert bec_list.count() == 0 + + def test_clear_widgets(self, bec_list, qtbot): + for key in ["a", "b", "c"]: + label = QtWidgets.QLabel(key) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + bec_list.clear_widgets() + + assert bec_list.count() == 0 + assert bec_list.get_widgets() == [] + assert bec_list.get_all_keys() == [] + + def test_get_widget_and_item(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item("key") + assert item is not None + assert bec_list.get_widget_for_item(item) == sample_widget + assert bec_list.get_widget("key") == sample_widget + + def test_get_item_for_widget(self, bec_list, sample_widget): + bec_list.add_widget_item("key", sample_widget) + + item = bec_list.get_item_for_widget(sample_widget) + assert item is not None + assert bec_list.itemWidget(item) == sample_widget + + def test_get_all_keys(self, bec_list, qtbot): + labels = [] + for key in ["k1", "k2", "k3"]: + label = QtWidgets.QLabel(key) + labels.append(label) + qtbot.addWidget(label) + qtbot.waitExposed(label) + bec_list.add_widget_item(key, label) + + assert sorted(bec_list.get_all_keys()) == ["k1", "k2", "k3"] + assert set(bec_list.get_widgets()) == set(labels) + + def test_get_widget_for_item_unknown(self, bec_list, sample_widget): + unrelated_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(unrelated_item) is None + + bec_list.add_widget_item("key", sample_widget) + other_item = QtWidgets.QListWidgetItem() + assert bec_list.get_widget_for_item(other_item) is None + + def test_get_item_for_widget_unknown(self, bec_list, qtbot): + label = QtWidgets.QLabel("orphan") + qtbot.addWidget(label) + qtbot.waitExposed(label) + assert bec_list.get_item_for_widget(label) is None + + def test_contains(self, bec_list, sample_widget): + assert "key" not in bec_list + bec_list.add_widget_item("key", sample_widget) + assert "key" in bec_list From 2ffe269727fac0c61ed524f1a2ca6486511bfe3f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 19 Dec 2025 13:59:02 +0100 Subject: [PATCH 051/155] fix(client): client API regenerated --- bec_widgets/cli/client.py | 915 +++++++++++++++++++++++++++++++++++--- 1 file changed, 848 insertions(+), 67 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index c163a9084..501db5979 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -27,7 +27,6 @@ class _WidgetsEnumType(str, enum.Enum): _Widgets = { - "AbortButton": "AbortButton", "BECDockArea": "BECDockArea", "BECMainWindow": "BECMainWindow", "BECProgressBar": "BECProgressBar", @@ -51,7 +50,6 @@ class _WidgetsEnumType(str, enum.Enum): "PositionerBox2D": "PositionerBox2D", "PositionerControlLine": "PositionerControlLine", "PositionerGroup": "PositionerGroup", - "ResetButton": "ResetButton", "ResumeButton": "ResumeButton", "RingProgressBar": "RingProgressBar", "SBBMonitor": "SBBMonitor", @@ -61,7 +59,6 @@ class _WidgetsEnumType(str, enum.Enum): "SignalComboBox": "SignalComboBox", "SignalLabel": "SignalLabel", "SignalLineEdit": "SignalLineEdit", - "StopButton": "StopButton", "TextBox": "TextBox", "VSCodeEditor": "VSCodeEditor", "Waveform": "Waveform", @@ -98,13 +95,136 @@ class _WidgetsEnumType(str, enum.Enum): logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}") -class AbortButton(RPCBase): - """A button that abort the scan.""" +class AdvancedDockArea(RPCBase): + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = None, + promote_central: "bool" = False, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Override the base helper so dock settings are available by default. + + The flag remains user-configurable (pass ``False`` to hide the action). + """ @rpc_call - def remove(self): + def dock_map(self) -> "dict[str, CDockWidget]": """ - Cleanup the BECConnector + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @property + @rpc_call + def lock_workspace(self) -> "bool": + """ + Get or set the lock state of the workspace. + + Returns: + bool: True if the workspace is locked, False otherwise. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @property + @rpc_call + def mode(self) -> "str": + """ + None + """ + + @mode.setter + @rpc_call + def mode(self) -> "str": + """ + None """ @@ -144,6 +264,26 @@ def selected_device(self) -> "str | None": """ +class AvailableDeviceResources(RPCBase): + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class BECDock(RPCBase): @property @rpc_call @@ -443,6 +583,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class BECProgressBar(RPCBase): """A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" @@ -526,6 +678,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class BECStatusBox(RPCBase): """An autonomous widget to display the status of BEC services.""" @@ -542,6 +706,25 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class BaseROI(RPCBase): """Base class for all Region of Interest (ROI) implementations.""" @@ -1003,6 +1186,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceBrowser(RPCBase): """DeviceBrowser is a widget that displays all available devices in the current BEC session.""" @@ -1013,6 +1208,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceComboBox(RPCBase): """Combobox widget for device input with autocomplete for device names.""" @@ -1046,6 +1253,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceLineEdit(RPCBase): """Line edit widget for device input with autocomplete for device names.""" @@ -1080,6 +1299,161 @@ def _is_valid_input(self) -> bool: """ +class DockAreaWidget(RPCBase): + """Lightweight dock area that exposes the core Qt ADS docking helpers without any""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + + @rpc_call + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + + class EllipticalROI(RPCBase): """Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" @@ -1211,6 +1585,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -1813,6 +2199,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -2520,72 +2918,233 @@ def num_rotation_90(self) -> "Optional[int]": @property @rpc_call - def transpose(self) -> "bool": + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + @transpose.setter + @rpc_call + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + @rpc_call + def get_data(self) -> "np.ndarray": + """ + Get the data of the image. + Returns: + np.ndarray: The data of the image. + """ + + +class LogPanel(RPCBase): + """Displays a log panel""" + + @rpc_call + def set_plain_text(self, text: str) -> None: + """ + Set the plain text of the widget. + + Args: + text (str): The text to set. + """ + + @rpc_call + def set_html_text(self, text: str) -> None: + """ + Set the HTML text of the widget. + + Args: + text (str): The text to set. + """ + + +class Minesweeper(RPCBase): ... + + +class MonacoDock(RPCBase): + """MonacoDock is a dock widget that contains Monaco editor instances.""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + + @rpc_call + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): """ - Get or set whether the image is transposed. + Re-attach floating docks back into the dock manager. """ - @transpose.setter @rpc_call - def transpose(self) -> "bool": + def delete_all(self): """ - Get or set whether the image is transposed. + Delete all docks and their associated widgets. """ @rpc_call - def get_data(self) -> "np.ndarray": + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": """ - Get the data of the image. - Returns: - np.ndarray: The data of the image. + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) """ + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. -class LogPanel(RPCBase): - """Displays a log panel""" + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ @rpc_call - def set_plain_text(self, text: str) -> None: + def print_layout_structure(self) -> "None": """ - Set the plain text of the widget. - - Args: - text (str): The text to set. + Pretty-print the current splitter paths to stdout. """ @rpc_call - def set_html_text(self, text: str) -> None: + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": """ - Set the HTML text of the widget. + Promote an existing dock to be the dock manager's central widget. Args: - text (str): The text to set. + dock(CDockWidget | QWidget | str): Dock reference to promote. """ -class Minesweeper(RPCBase): ... - - class MonacoWidget(RPCBase): """A simple Monaco editor widget""" @rpc_call - def set_text(self, text: str) -> None: + def set_text( + self, text: "str", file_name: "str | None" = None, reset: "bool" = False + ) -> "None": """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ @rpc_call - def get_text(self) -> str: + def get_text(self) -> "str": """ Get the current text from the Monaco editor. """ @rpc_call - def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: + def insert_text( + self, text: "str", line: "int | None" = None, column: "int | None" = None + ) -> "None": """ Insert text at the current cursor position or at a specified line and column. @@ -2596,7 +3155,7 @@ def insert_text(self, text: str, line: int | None = None, column: int | None = N """ @rpc_call - def delete_line(self, line: int | None = None) -> None: + def delete_line(self, line: "int | None" = None) -> "None": """ Delete a line in the Monaco editor. @@ -2605,7 +3164,16 @@ def delete_line(self, line: int | None = None) -> None: """ @rpc_call - def set_language(self, language: str) -> None: + def open_file(self, file_name: "str") -> "None": + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + @rpc_call + def set_language(self, language: "str") -> "None": """ Set the programming language for syntax highlighting in the Monaco editor. @@ -2614,13 +3182,13 @@ def set_language(self, language: str) -> None: """ @rpc_call - def get_language(self) -> str: + def get_language(self) -> "str": """ Get the current programming language set in the Monaco editor. """ @rpc_call - def set_theme(self, theme: str) -> None: + def set_theme(self, theme: "str") -> "None": """ Set the theme for the Monaco editor. @@ -2629,13 +3197,13 @@ def set_theme(self, theme: str) -> None: """ @rpc_call - def get_theme(self) -> str: + def get_theme(self) -> "str": """ Get the current theme of the Monaco editor. """ @rpc_call - def set_readonly(self, read_only: bool) -> None: + def set_readonly(self, read_only: "bool") -> "None": """ Set the Monaco editor to read-only mode. @@ -2646,10 +3214,10 @@ def set_readonly(self, read_only: bool) -> None: @rpc_call def set_cursor( self, - line: int, - column: int = 1, - move_to_position: Literal[None, "center", "top", "position"] = None, - ) -> None: + line: "int", + column: "int" = 1, + move_to_position: "Literal[None, 'center', 'top', 'position']" = None, + ) -> "None": """ Set the cursor position in the Monaco editor. @@ -2660,7 +3228,7 @@ def set_cursor( """ @rpc_call - def current_cursor(self) -> dict[str, int]: + def current_cursor(self) -> "dict[str, int]": """ Get the current cursor position in the Monaco editor. @@ -2669,7 +3237,7 @@ def current_cursor(self) -> dict[str, int]: """ @rpc_call - def set_minimap_enabled(self, enabled: bool) -> None: + def set_minimap_enabled(self, enabled: "bool") -> "None": """ Enable or disable the minimap in the Monaco editor. @@ -2678,7 +3246,7 @@ def set_minimap_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_vim_mode_enabled(self, enabled: bool) -> None: + def set_vim_mode_enabled(self, enabled: "bool") -> "None": """ Enable or disable Vim mode in the Monaco editor. @@ -2687,7 +3255,7 @@ def set_vim_mode_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_lsp_header(self, header: str) -> None: + def set_lsp_header(self, header: "str") -> "None": """ Set the LSP (Language Server Protocol) header for the Monaco editor. The header is used to provide context for language servers but is not displayed in the editor. @@ -2697,7 +3265,7 @@ def set_lsp_header(self, header: str) -> None: """ @rpc_call - def get_lsp_header(self) -> str: + def get_lsp_header(self) -> "str": """ Get the current LSP header set in the Monaco editor. @@ -2705,6 +3273,25 @@ def get_lsp_header(self) -> str: str: The LSP header. """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class MotorMap(RPCBase): """Motor map widget for plotting motor positions in 2D including a trace of the last points.""" @@ -2715,6 +3302,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -3107,7 +3706,9 @@ def scatter_size(self) -> "int": """ @rpc_call - def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None": + def map( + self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False + ) -> "None": """ Set the x and y motor names. @@ -3115,6 +3716,7 @@ def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "Non x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ @rpc_call @@ -3132,6 +3734,34 @@ def get_data(self) -> "dict": dict: Data of the motor map. """ + @property + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @x_motor.setter + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @property + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + + @y_motor.setter + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + class MultiWaveform(RPCBase): """MultiWaveform widget for displaying multiple waveforms emitted by a single signal.""" @@ -3142,6 +3772,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -3788,6 +4430,18 @@ def set_positioner(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3817,6 +4471,18 @@ def set_positioner_ver(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3865,6 +4531,18 @@ def set_positioner(self, positioner: "str | Positioner"): positioner (Positioner | str) : Positioner to set, accepts str or the device """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @rpc_timeout(None) @rpc_call def screenshot(self, file_name: "str | None" = None): @@ -3884,6 +4562,25 @@ def set_positioners(self, device_names: "str"): Device names must be separated by space """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class RectangularROI(RPCBase): """Defines a rectangular Region of Interest (ROI) with additional functionality.""" @@ -4014,8 +4711,8 @@ def set_position(self, x: "float", y: "float"): """ -class ResetButton(RPCBase): - """A button that resets the scan queue.""" +class ResumeButton(RPCBase): + """A button that continue scan queue.""" @rpc_call def remove(self): @@ -4023,14 +4720,16 @@ def remove(self): Cleanup the BECConnector """ - -class ResumeButton(RPCBase): - """A button that continue scan queue.""" + @rpc_call + def attach(self): + """ + None + """ @rpc_call - def remove(self): + def detach(self): """ - Cleanup the BECConnector + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @@ -4314,6 +5013,25 @@ def enable_auto_updates(self, enable: "bool" = True): bool: True if scan segment updates are enabled. """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ + class SBBMonitor(RPCBase): """A widget to display the SBB monitor website.""" @@ -4325,9 +5043,15 @@ class ScanControl(RPCBase): """Widget to submit new scans to the queue.""" @rpc_call - def remove(self): + def attach(self): """ - Cleanup the BECConnector + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @rpc_timeout(None) @@ -4347,6 +5071,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class ScatterCurve(RPCBase): """Scatter curve item for the scatter waveform widget.""" @@ -4366,6 +5102,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -4953,16 +5701,6 @@ def signals(self) -> list[str]: """ -class StopButton(RPCBase): - """A button that stops the current scan.""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - class TextBox(RPCBase): """A widget that displays text in plain and HTML format""" @@ -5000,6 +5738,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + @property @rpc_call def enable_toolbar(self) -> "bool": @@ -5559,6 +6309,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class WebsiteWidget(RPCBase): """A simple widget to display a website""" @@ -5598,3 +6360,22 @@ def forward(self): """ Go forward in the history """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ From 4a2bc9fcd97e97fd206926326394122754718340 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 26 Nov 2025 10:56:14 +0100 Subject: [PATCH 052/155] feat(developer_widget): add signal connection for focused editor changes to disable run button for macro files --- .../views/developer_view/developer_widget.py | 23 ++++++++++ tests/unit_tests/test_developer_view.py | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index ce7030c3a..acb56d22d 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -124,6 +124,7 @@ def __init__(self, parent=None, **kwargs): # Connect editor signals self.explorer.file_open_requested.connect(self._open_new_file) self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) + self.monaco.focused_editor.connect(self._on_focused_editor_changed) self.toolbar.show_bundles(["save", "execution", "settings"]) @@ -336,6 +337,28 @@ def _update_subscription(self, new_script_id: str | None, old_script_id: str | N self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id) ) + @SafeSlot(CDockWidget) + def _on_focused_editor_changed(self, tab_widget: CDockWidget): + """ + Disable the run / stop buttons if the focused editor is a macro file. + Args: + tab_widget: The currently focused tab widget in the Monaco editor. + """ + if not isinstance(tab_widget, CDockWidget): + return + widget = tab_widget.widget() + if not isinstance(widget, MonacoWidget): + return + file_scope = widget.metadata.get("scope", "") + run_action = self.toolbar.components.get_action("run") + stop_action = self.toolbar.components.get_action("stop") + if "macro" in file_scope: + run_action.action.setEnabled(False) + stop_action.action.setEnabled(False) + else: + run_action.action.setEnabled(True) + stop_action.action.setEnabled(True) + @SafeSlot(dict, dict) def on_script_execution_info(self, content: dict, metadata: dict): """ diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py index 56971d3b7..f4b756fcb 100644 --- a/tests/unit_tests/test_developer_view.py +++ b/tests/unit_tests/test_developer_view.py @@ -322,6 +322,52 @@ def test_vim_mode_button_toggle(self, developer_view, qtbot): # Check that state changed assert vim_action.action.isChecked() != initial_state + def test_run_stop_buttons_disabled_for_macros(self, developer_view, temp_python_file, qtbot): + """Test that run and stop buttons are disabled when a macro file is focused.""" + # Open a file with macro scope + developer_view._open_new_file(temp_python_file, "macros/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Get the editor dock for the macro file + dock = developer_view.monaco._get_editor_dock(temp_python_file) + assert dock is not None + + # Simulate focusing on the macro file + developer_view._on_focused_editor_changed(dock) + + # Check that run and stop buttons are disabled + run_action = developer_view.toolbar.components.get_action("run") + stop_action = developer_view.toolbar.components.get_action("stop") + + assert not run_action.action.isEnabled() + assert not stop_action.action.isEnabled() + + def test_run_stop_buttons_enabled_for_scripts(self, developer_view, temp_python_file, qtbot): + """Test that run and stop buttons are enabled when a script file is focused.""" + # Open a file with script scope + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Get the editor dock for the script file + dock = developer_view.monaco._get_editor_dock(temp_python_file) + assert dock is not None + + # Simulate focusing on the script file + developer_view._on_focused_editor_changed(dock) + + # Check that run and stop buttons are enabled + run_action = developer_view.toolbar.components.get_action("run") + stop_action = developer_view.toolbar.components.get_action("stop") + + assert run_action.action.isEnabled() + assert stop_action.action.isEnabled() + class TestErrorHandling: """Test error handling in various scenarios.""" From e607d34337200f8538e19412d185ba04a5f44d3e Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 26 Nov 2025 10:56:30 +0100 Subject: [PATCH 053/155] refactor(developer_widget): enhance documentation and add missing imports --- .../views/developer_view/developer_widget.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index acb56d22d..68fcfd8f9 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re import markdown @@ -13,6 +15,7 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget from bec_widgets.widgets.editors.web_console.web_console import WebConsole @@ -281,14 +284,17 @@ def _open_new_file(self, file_name: str, scope: str): @SafeSlot() def on_save(self): + """Save the currently focused file in the Monaco editor.""" self.monaco.save_file() @SafeSlot() def on_save_as(self): + """Save the currently focused file in the Monaco editor with a 'Save As' dialog.""" self.monaco.save_file(force_save_as=True) @SafeSlot() def on_vim_triggered(self): + """Toggle Vim mode in the Monaco editor.""" self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) @SafeSlot(bool) @@ -311,16 +317,26 @@ def on_execute(self): @SafeSlot() def on_stop(self): + """Stop the execution of the currently running script""" if not self.current_script_id: return self.console.send_ctrl_c() @property def current_script_id(self): + """Get the ID of the currently running script.""" return self._current_script_id @current_script_id.setter def current_script_id(self, value: str | None): + """ + Set the ID of the currently running script. + + Args: + value (str | None): The script ID to set. + Raises: + ValueError: If the provided value is not a string or None. + """ if value is not None and not isinstance(value, str): raise ValueError("Script ID must be a string.") old_script_id = self._current_script_id @@ -382,6 +398,7 @@ def on_script_execution_info(self, content: dict, metadata: dict): widget.set_highlighted_lines(line_number, line_number) def cleanup(self): + """Clean up resources used by the developer widget.""" self.delete_all() return super().cleanup() From 2d4eabead0dd201d28bd2d83f983ab57f60f64ee Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 26 Nov 2025 10:56:41 +0100 Subject: [PATCH 054/155] fix(monaco_dock): update editor metadata handling and improve open_file method --- .../widgets/editors/monaco/monaco_dock.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 3186d82c4..65c21e713 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -142,9 +142,12 @@ def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): # Temporarily disable read-only mode if the editor is read-only # so we can clear the content for reuse monaco_widget.set_readonly(False) - monaco_widget.set_text("") + monaco_widget.set_text("", reset=True) dock.setWindowTitle("Untitled") dock.setTabToolTip("Untitled") + monaco_widget.metadata["scope"] = "" + icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True) + dock.setIcon(icon) return # Otherwise, proceed to close and delete the dock @@ -249,10 +252,15 @@ def add_editor( self.last_focused_editor = dock return dock - def open_file(self, file_name: str, scope: str | None = None) -> None: + def open_file(self, file_name: str, scope: str = "") -> None: """ Open a file in the specified area. If the file is already open, activate it. + + Args: + file_name (str): The path to the file to open. + scope (str): The scope to set for the editor metadata. """ + open_files = self._get_open_files() if file_name in open_files: dock = self._get_editor_dock(file_name) @@ -281,8 +289,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: editor_dock.setWindowTitle(file) editor_dock.setTabToolTip(file_name) editor_widget.open_file(file_name) - if scope is not None: - editor_widget.metadata["scope"] = scope + editor_widget.metadata["scope"] = scope self.last_focused_editor = editor_dock return @@ -290,8 +297,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: editor_dock = self.add_editor(title=file, tooltip=file_name) widget = cast(MonacoWidget, editor_dock.widget()) widget.open_file(file_name) - if scope is not None: - widget.metadata["scope"] = scope + widget.metadata["scope"] = scope editor_dock.setAsCurrentTab() self.last_focused_editor = editor_dock From dd7a5e11df516eab0c181443b0f88354d276daa3 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 7 Jan 2026 14:03:23 +0100 Subject: [PATCH 055/155] fix(monaco dock): update last focused editor when closing --- .../widgets/editors/monaco/monaco_dock.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 65c21e713..a2b738a2e 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -141,13 +141,12 @@ def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): # Do not remove the last dock; just wipe its editor content # Temporarily disable read-only mode if the editor is read-only # so we can clear the content for reuse - monaco_widget.set_readonly(False) - monaco_widget.set_text("", reset=True) + self.reset_widget(monaco_widget) dock.setWindowTitle("Untitled") dock.setTabToolTip("Untitled") - monaco_widget.metadata["scope"] = "" icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True) dock.setIcon(icon) + self.last_focused_editor = dock return # Otherwise, proceed to close and delete the dock @@ -159,6 +158,17 @@ def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): # After topology changes, make sure single-tab areas get a plus button QTimer.singleShot(0, self._scan_and_fix_areas) + def reset_widget(self, widget: MonacoWidget): + """ + Reset the given Monaco editor widget to its initial state. + + Args: + widget (MonacoWidget): The Monaco editor widget to reset. + """ + widget.set_readonly(False) + widget.set_text("", reset=True) + widget.metadata["scope"] = "" + def _ensure_area_plus(self, area): if area is None: return From f121d09baa41595debb7e03c1f92c62ee114dda5 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 7 Jan 2026 14:04:02 +0100 Subject: [PATCH 056/155] fix(monaco widget): reset current_file --- bec_widgets/widgets/editors/monaco/monaco_widget.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 25fd2b3d8..b1ec71e68 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -110,9 +110,12 @@ def set_text(self, text: str, file_name: str | None = None, reset: bool = False) file_name (str): Set the file name reset (bool): If True, reset the original content to the new text. """ - self._current_file = file_name if file_name else self._current_file if reset: + self._current_file = file_name self._original_content = text + else: + self._current_file = file_name if file_name else self._current_file + self.editor.set_text(text, uri=file_name) def get_text(self) -> str: From 1bb0f1a85548d4a78967532bf6856192832ae968 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 7 Jan 2026 15:13:48 +0100 Subject: [PATCH 057/155] fix(developer widget): save before executing a scripts --- .../applications/views/developer_view/developer_widget.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 68fcfd8f9..76f482bca 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -311,6 +311,12 @@ def on_execute(self): widget = self.script_editor_tab.widget() if not isinstance(widget, MonacoWidget): return + if widget.modified: + # Save the file before execution if there are unsaved changes + self.monaco.save_file() + if widget.modified: + # If still modified, user likely cancelled save dialog + return self.current_script_id = upload_script(self.client.connector, widget.get_text()) self.console.write(f'bec._run_script("{self.current_script_id}")') print(f"Uploaded script with ID: {self.current_script_id}") From da23a47213de6b859ca22120535086ed41b0b989 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 8 Jan 2026 18:22:50 +0100 Subject: [PATCH 058/155] ci: use shared issue sync action instead of local version --- .../scripts/pr_issue_sync/pr_issue_sync.py | 342 ------------------ .../scripts/pr_issue_sync/requirements.txt | 2 - .github/workflows/sync-issues-pr.yml | 43 +-- 3 files changed, 19 insertions(+), 368 deletions(-) delete mode 100644 .github/scripts/pr_issue_sync/pr_issue_sync.py delete mode 100644 .github/scripts/pr_issue_sync/requirements.txt diff --git a/.github/scripts/pr_issue_sync/pr_issue_sync.py b/.github/scripts/pr_issue_sync/pr_issue_sync.py deleted file mode 100644 index 82506cc7f..000000000 --- a/.github/scripts/pr_issue_sync/pr_issue_sync.py +++ /dev/null @@ -1,342 +0,0 @@ -import functools -import os -from typing import Literal - -import requests -from github import Auth, Github -from pydantic import BaseModel - - -class GHConfig(BaseModel): - token: str - organization: str - repository: str - project_number: int - graphql_url: str - rest_url: str - headers: dict - - -class ProjectItemHandler: - """ - A class to handle GitHub project items. - """ - - def __init__(self, gh_config: GHConfig): - self.gh_config = gh_config - self.gh = Github(auth=Auth.Token(gh_config.token)) - self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}") - self.project_node_id = self.get_project_node_id() - - def set_issue_status( - self, - status: Literal[ - "Selected for Development", - "Weekly Backlog", - "In Development", - "Ready For Review", - "On Hold", - "Done", - ], - issue_number: int | None = None, - issue_node_id: str | None = None, - ): - """ - Set the status field of a GitHub issue in the project. - - Args: - status (str): The status to set. Must be one of the predefined statuses. - issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided. - issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided. - """ - if not issue_number and not issue_node_id: - raise ValueError("Either issue_number or issue_node_id must be provided.") - if issue_number and issue_node_id: - raise ValueError("Only one of issue_number or issue_node_id must be provided.") - if issue_number is not None: - issue = self.repo.get_issue(issue_number) - issue_id = self.get_issue_info(issue.node_id)[0]["id"] - else: - issue_id = issue_node_id - field_id, option_id = self.get_status_field_id(field_name=status) - self.set_field_option(issue_id, field_id, option_id) - - def run_graphql(self, query: str, variables: dict) -> dict: - """ - Execute a GraphQL query against the GitHub API. - - Args: - query (str): The GraphQL query to execute. - variables (dict): The variables to pass to the query. - - Returns: - dict: The response from the GitHub API. - """ - response = requests.post( - self.gh_config.graphql_url, - json={"query": query, "variables": variables}, - headers=self.gh_config.headers, - timeout=10, - ) - if response.status_code != 200: - raise Exception( - f"Query failed with status code {response.status_code}: {response.text}" - ) - return response.json() - - def get_project_node_id(self): - """ - Retrieve the project node ID from the GitHub API. - """ - query = """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - } - } - } - """ - variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number} - resp = self.run_graphql(query, variables) - return resp["data"]["organization"]["projectV2"]["id"] - - def get_issue_info(self, issue_node_id: str): - """ - Get the project-related information for a given issue node ID. - - Args: - issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I". - - Returns: - list[dict]: A list of project items associated with the issue. - """ - query = """ - query($issueId: ID!) { - node(id: $issueId) { - ... on Issue { - projectItems(first: 10) { - nodes { - project { - id - title - } - id - fieldValues(first: 20) { - nodes { - ... on ProjectV2ItemFieldSingleSelectValue { - name - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - } - } - } - } - } - } - } - """ - variables = {"issueId": issue_node_id} - resp = self.run_graphql(query, variables) - return resp["data"]["node"]["projectItems"]["nodes"] - - def get_status_field_id( - self, - field_name: Literal[ - "Selected for Development", - "Weekly Backlog", - "In Development", - "Ready For Review", - "On Hold", - "Done", - ], - ) -> tuple[str, str]: - """ - Get the status field ID and option ID for the given field name in the project. - - Args: - field_name (str): The name of the field to retrieve. - Must be one of the predefined statuses. - - Returns: - tuple[str, str]: A tuple containing the field ID and option ID. - """ - field_id = None - option_id = None - project_fields = self.get_project_fields() - for field in project_fields: - if field["name"] != "Status": - continue - field_id = field["id"] - for option in field["options"]: - if option["name"] == field_name: - option_id = option["id"] - break - if not field_id or not option_id: - raise ValueError(f"Field '{field_name}' not found in project fields.") - - return field_id, option_id - - def set_field_option(self, item_id, field_id, option_id): - """ - Set the option of a project item for a single-select field. - - Args: - item_id (str): The ID of the project item to update. - field_id (str): The ID of the field to update. - option_id (str): The ID of the option to set. - """ - - mutation = """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - } - ) { - projectV2Item { - id - } - } - } - """ - variables = { - "projectId": self.project_node_id, - "itemId": item_id, - "fieldId": field_id, - "optionId": option_id, - } - return self.run_graphql(mutation, variables) - - @functools.lru_cache(maxsize=1) - def get_project_fields(self) -> list[dict]: - """ - Get the available fields in the project. - This method caches the result to avoid multiple API calls. - - Returns: - list[dict]: A list of fields in the project. - """ - - query = """ - query($projectId: ID!) { - node(id: $projectId) { - ... on ProjectV2 { - fields(first: 50) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { - id - name - } - } - } - } - } - } - } - """ - variables = {"projectId": self.project_node_id} - resp = self.run_graphql(query, variables) - return list(filter(bool, resp["data"]["node"]["fields"]["nodes"])) - - def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]: - """ - Get the linked issues of a pull request. - - Args: - pr_number (int): The pull request number. - - Returns: - list[dict]: A list of linked issues. - """ - query = """ - query($number: Int!, $owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - closingIssuesReferences(first: 50) { - edges { - node { - id - body - number - title - } - } - } - } - } - } - """ - variables = { - "number": pr_number, - "owner": self.gh_config.organization, - "repo": self.gh_config.repository, - } - resp = self.run_graphql(query, variables) - edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"] - return [edge["node"] for edge in edges if edge.get("node")] - - -def main(): - # GitHub settings - token = os.getenv("TOKEN") - org = os.getenv("ORG") - repo = os.getenv("REPO") - project_number = os.getenv("PROJECT_NUMBER") - pr_number = os.getenv("PR_NUMBER") - - if not token: - raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.") - if not org: - raise ValueError("GitHub organization is not set. Please set the ORG environment variable.") - if not repo: - raise ValueError("GitHub repository is not set. Please set the REPO environment variable.") - if not project_number: - raise ValueError( - "GitHub project number is not set. Please set the PROJECT_NUMBER environment variable." - ) - if not pr_number: - raise ValueError( - "Pull request number is not set. Please set the PR_NUMBER environment variable." - ) - - project_number = int(project_number) - pr_number = int(pr_number) - - gh_config = GHConfig( - token=token, - organization=org, - repository=repo, - project_number=project_number, - graphql_url="https://api.github.com/graphql", - rest_url=f"https://api.github.com/repos/{org}/{repo}/issues", - headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, - ) - project_item_handler = ProjectItemHandler(gh_config=gh_config) - - # Get PR info - pr = project_item_handler.repo.get_pull(pr_number) - - # Get the linked issues of the pull request - linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number) - print(f"Linked issues: {linked_issues}") - - target_status = "In Development" if pr.draft else "Ready For Review" - print(f"Target status: {target_status}") - for issue in linked_issues: - project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/pr_issue_sync/requirements.txt b/.github/scripts/pr_issue_sync/requirements.txt deleted file mode 100644 index 9f191a9e3..000000000 --- a/.github/scripts/pr_issue_sync/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pydantic -pygithub \ No newline at end of file diff --git a/.github/workflows/sync-issues-pr.yml b/.github/workflows/sync-issues-pr.yml index f40facc84..326dfe532 100644 --- a/.github/workflows/sync-issues-pr.yml +++ b/.github/workflows/sync-issues-pr.yml @@ -2,7 +2,18 @@ name: Sync PR to Project on: pull_request: - types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize] + types: + [ + opened, + assigned, + unassigned, + edited, + ready_for_review, + converted_to_draft, + reopened, + synchronize, + closed, + ] jobs: sync-project: @@ -13,28 +24,12 @@ jobs: pull-requests: read contents: read - env: - PROJECT_NUMBER: 3 # BEC Project - ORG: 'bec-project' - REPO: 'bec_widgets' - TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }} - PR_NUMBER: ${{ github.event.pull_request.number }} - steps: - - name: Set up python environment - uses: actions/setup-python@v4 - with: - python-version: 3.11 - - - name: Checkout repo - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ github.event.pull_request.head.ref }} - - - name: Install dependencies - run: | - pip install -r ./.github/scripts/pr_issue_sync/requirements.txt - name: Sync PR to Project - run: | - python ./.github/scripts/pr_issue_sync/pr_issue_sync.py \ No newline at end of file + uses: bec-project/action-issue-sync-pr@v1 + with: + token: ${{ secrets.ADD_ISSUE_TO_PROJECT }} + org: ${{ github.repository_owner }} + repo: ${{ github.event.repository.name }} + project-number: 3 + pr-number: ${{ github.event.pull_request.number }} From 15ac1c0182cfbca1841be5e21fec88909cc2ebd5 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 13 Jan 2026 15:08:51 +0100 Subject: [PATCH 059/155] fix(main_app): refactor main function and update script entry point in pyproject.toml --- bec_widgets/applications/main_app.py | 11 ++++++++++- pyproject.toml | 10 +++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 6f4ba3547..ef5aeae96 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -193,7 +193,12 @@ def _on_view_selected(self, vid: str) -> None: new_view.on_enter() -if __name__ == "__main__": # pragma: no cover +def main(): # pragma: no cover + """ + Main function to run the BEC main application, exposed as a script entry point through + pyproject.toml. + """ + # pylint: disable=import-outside-toplevel import argparse import sys @@ -225,3 +230,7 @@ def _on_view_selected(self, vid: str) -> None: w.show() sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/pyproject.toml b/pyproject.toml index 228d597b1..a55d4824a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,16 +13,16 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "bec_ipython_client~=3.70", # needed for jupyter console + "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", "bec_qthemes~=1.0, >=1.1.2", - "black~=25.0", # needed for bw-generate-cli - "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "black~=25.0", # needed for bw-generate-cli + "isort~=5.13, >=5.13.2", # needed for bw-generate-cli "ophyd_devices~=1.29, >=1.29.1", "pydantic~=2.0", "pyqtgraph==0.13.7", "PySide6==6.9.0", - "qtconsole~=5.5, >=5.5.1", # needed for jupyter console + "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", "thefuzz~=0.22", "qtmonaco~=0.8, >=0.8.1", @@ -58,7 +58,7 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets" bw-generate-cli = "bec_widgets.cli.generate_cli:main" bec-gui-server = "bec_widgets.cli.server:main" bec-designer = "bec_widgets.utils.bec_designer:main" -bec-app = "bec_widgets.applications.bec_app:main" +bec-app = "bec_widgets.applications.main_app:main" [tool.hatch.build.targets.wheel] include = ["*"] From 21862e802164813752393d6416caca2dcf5e6028 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 14 Jan 2026 14:15:08 +0100 Subject: [PATCH 060/155] fix(main_app): center the application window on the screen --- bec_widgets/applications/main_app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index ef5aeae96..1157e32b5 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -227,6 +227,12 @@ def main(): # pragma: no cover height = int(width / (16 / 9)) w.resize(width, height) + + # Center the window on the screen + x = screen_geometry.x() + (screen_geometry.width() - width) // 2 + y = screen_geometry.y() + (screen_geometry.height() - height) // 2 + w.move(x, y) + w.show() sys.exit(app.exec()) From b585a608c7977c5c1918ed992e865309b08fb8f7 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 6 Jan 2026 15:43:43 +0100 Subject: [PATCH 061/155] fix(main_window): delete on close --- bec_widgets/widgets/containers/main_window/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 34a01875f..2878d2686 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -50,6 +50,7 @@ def __init__(self, parent=None, window_title: str = "BEC", **kwargs): self.app = QApplication.instance() self.status_bar = self.statusBar() self.setWindowTitle(window_title) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) # Notification Centre overlay self.notification_centre = NotificationCentre(parent=self) # Notification layer From c02f509867ba19ada937c4c9568c4f8707907229 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 8 Jan 2026 23:37:26 +0100 Subject: [PATCH 062/155] fix(basic_dock_area): delete_all will also delete floating docks --- .../widgets/containers/advanced_dock_area/basic_dock_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 5c4ec573e..3331619d7 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -1372,7 +1372,7 @@ def attach_all(self): @SafeSlot() def delete_all(self): """Delete all docks and their associated widgets.""" - for dock in list(self.dock_manager.dockWidgets()): + for dock in self.dock_list(): self._delete_dock(dock) From dab1defc76eb29b37067ac5498b6a3ba2f8990cb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 6 Jan 2026 11:20:57 +0100 Subject: [PATCH 063/155] fix(advanced_dock_area): remove all widgets when loading new profiles --- .../containers/advanced_dock_area/advanced_dock_area.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 2e4bce02e..2c46fdb5e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -688,6 +688,9 @@ def load_profile(self, name: str | None = None): QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") return + # Clear existing docks and remove all widgets + self.delete_all() + # Rebuild widgets and restore states for item in read_manifest(settings): obj_name = item["object_name"] From 6b1d2958c3ec2ae6ecb324bda773329509bf84d2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Jan 2026 17:51:02 +0100 Subject: [PATCH 064/155] fix(advanced_dock_area): ensure the general profile exists when launched first time --- .../advanced_dock_area/advanced_dock_area.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 2c46fdb5e..13f0db9b2 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -37,6 +37,7 @@ get_last_profile, is_profile_read_only, is_quick_select, + list_profiles, list_quick_profiles, load_default_profile_screenshot, load_user_profile_screenshot, @@ -170,6 +171,9 @@ def __init__( # Initialize default editable state based on current lock self._set_editable(True) # default to editable; will sync toolbar toggle below + if self._ensure_initial_profile(): + self._refresh_workspace_list() + # Sync Developer toggle icon state after initial setup #TODO temporary disable # dev_action = self.toolbar.components.get_action("developer_mode").action # dev_action.setChecked(self._editable) @@ -179,6 +183,44 @@ def __init__( if self._restore_initial_profile: self._fetch_initial_profile() + def _ensure_initial_profile(self) -> bool: + """ + Ensure at least one workspace profile exists for the current namespace. + + Returns: + bool: True if a profile was created, False otherwise. + """ + namespace = self.profile_namespace + try: + existing_profiles = list_profiles(namespace) + except Exception as exc: # pragma: no cover - defensive guard + logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}") + return False + + if existing_profiles: + return False + + name = "general" + logger.info( + f"No profiles found for namespace '{namespace}'. Bootstrapping '{name}' workspace." + ) + + default_settings = open_default_settings(name, namespace=namespace) + self._write_snapshot_to_settings(default_settings, save_preview=False) + if not default_settings.value(SETTINGS_KEYS["created_at"], ""): + default_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + default_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) + + user_settings = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(user_settings, save_preview=False) + if not user_settings.value(SETTINGS_KEYS["created_at"], ""): + user_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + user_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) + + set_quick_select(name, True, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) + return True + def _fetch_initial_profile(self): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget From 67650b96a235645822bce8ecbde2f5d9bcd66dea Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Jan 2026 17:24:13 +0100 Subject: [PATCH 065/155] fix(advanced_dock_area): new profiles are saved with quickselect as default --- .../advanced_dock_area/advanced_dock_area.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 13f0db9b2..17c1fc0f5 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -633,7 +633,7 @@ def _profile_exists(profile_name: str) -> bool: return profile_origin(profile_name, namespace=namespace) != "unknown" initial_name = name or "" - quickselect_default = is_quick_select(name, namespace=namespace) if name else False + quickselect_default = is_quick_select(name, namespace=namespace) if name else True current_profile = getattr(self, "_current_profile_name", "") or "" dialog = SaveProfileDialog( @@ -668,22 +668,20 @@ def _profile_exists(profile_name: str) -> bool: self._write_snapshot_to_settings(ds) if not ds.value(SETTINGS_KEYS["created_at"], ""): ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are not quick-select by default + # Ensure new profiles are quick-select by default if not ds.value(SETTINGS_KEYS["is_quick_select"], None): - ds.setValue(SETTINGS_KEYS["is_quick_select"], False) + ds.setValue(SETTINGS_KEYS["is_quick_select"], True) # Always (over)write the user copy us = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(us) if not us.value(SETTINGS_KEYS["created_at"], ""): us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are not quick-select by default (only if missing) + # Ensure new profiles are quick-select by default (only if missing) if not us.value(SETTINGS_KEYS["is_quick_select"], None): - us.setValue(SETTINGS_KEYS["is_quick_select"], False) + us.setValue(SETTINGS_KEYS["is_quick_select"], True) - # set quick select - if quickselect: - set_quick_select(name, quickselect, namespace=namespace) + set_quick_select(name, quickselect, namespace=namespace) self._refresh_workspace_list() if current_profile and current_profile != name and not dialog.overwrite_existing: From 2132ace01bfe4beb2a8ba270e7a880e51f0a87e3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 8 Jan 2026 23:41:41 +0100 Subject: [PATCH 066/155] fix(advanced_dock_area): removed non-functional dock_list and dock_map from RPC --- bec_widgets/cli/client.py | 12 ------------ .../advanced_dock_area/advanced_dock_area.py | 2 -- 2 files changed, 14 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 501db5979..46797a0b6 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -122,18 +122,6 @@ def new( The flag remains user-configurable (pass ``False`` to hide the action). """ - @rpc_call - def dock_map(self) -> "dict[str, CDockWidget]": - """ - Return the dock widgets map as dictionary with names as keys. - """ - - @rpc_call - def dock_list(self) -> "list[CDockWidget]": - """ - Return the list of dock widgets. - """ - @rpc_call def widget_map(self) -> "dict[str, QWidget]": """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 17c1fc0f5..5c223e1ba 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -94,8 +94,6 @@ class AdvancedDockArea(DockAreaWidget): PLUGIN = False USER_ACCESS = [ "new", - "dock_map", - "dock_list", "widget_map", "widget_list", "lock_workspace", From 24cc8c7b983e55a73ed60efa0721ae89a1f1be16 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 11 Dec 2025 18:42:11 +0100 Subject: [PATCH 067/155] fix(dock_area): the old BECDockArea(pg) removed and replaces by AdvancedDockArea(ADS) --- bec_widgets/applications/bw_launch.py | 27 +- bec_widgets/applications/launch_window.py | 30 +- bec_widgets/cli/client.py | 293 -------- .../jupyter_console/jupyter_console_window.py | 11 - bec_widgets/utils/bec_connector.py | 9 +- bec_widgets/utils/bec_widget.py | 5 +- .../advanced_dock_area/advanced_dock_area.py | 36 +- .../containers/auto_update/auto_updates.py | 29 +- .../widgets/containers/dock/__init__.py | 2 - .../containers/dock/bec_dock_area.pyproject | 1 - .../containers/dock/bec_dock_area_plugin.py | 57 -- bec_widgets/widgets/containers/dock/dock.py | 440 ------------ .../widgets/containers/dock/dock_area.py | 633 ------------------ .../containers/dock/register_bec_dock_area.py | 15 - .../device_item/device_signal_display.py | 4 +- tests/end-2-end/conftest.py | 4 + tests/end-2-end/test_bec_dock_rpc_e2e.py | 113 ++-- tests/end-2-end/test_bec_gui_ipython.py | 2 +- .../end-2-end/test_plotting_framework_e2e.py | 53 +- tests/end-2-end/test_rpc_register_e2e.py | 10 +- tests/end-2-end/test_rpc_widgets_e2e.py | 33 +- tests/end-2-end/user_interaction/conftest.py | 4 + .../test_user_interaction_e2e.py | 175 +++-- tests/unit_tests/test_bec_dock.py | 233 ------- .../unit_tests/test_client_plugin_widgets.py | 3 +- tests/unit_tests/test_client_utils.py | 8 +- tests/unit_tests/test_plugin_utils.py | 2 +- tests/unit_tests/test_rpc_widget_handler.py | 9 +- 28 files changed, 263 insertions(+), 1978 deletions(-) delete mode 100644 bec_widgets/widgets/containers/dock/__init__.py delete mode 100644 bec_widgets/widgets/containers/dock/bec_dock_area.pyproject delete mode 100644 bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py delete mode 100644 bec_widgets/widgets/containers/dock/dock.py delete mode 100644 bec_widgets/widgets/containers/dock/dock_area.py delete mode 100644 bec_widgets/widgets/containers/dock/register_bec_dock_area.py delete mode 100644 tests/unit_tests/test_bec_dock.py diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 33500a1d4..3872532ad 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -1,12 +1,31 @@ from __future__ import annotations +from bec_lib import bec_logger + +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +logger = bec_logger.logger + + +def dock_area(object_name: str | None = None, profile: str | None = None) -> AdvancedDockArea: + """ + Create an advanced dock area using Qt Advanced Docking System. + + Args: + object_name(str): The name of the advanced dock area. + profile(str|None): Optional profile to load; if None the last profile is restored. -def dock_area(object_name: str | None = None) -> BECDockArea: - _dock_area = BECDockArea(object_name=object_name, root_widget=True) - return _dock_area + Returns: + AdvancedDockArea: The created advanced dock area. + """ + widget = AdvancedDockArea( + object_name=object_name, restore_initial_profile=(profile is None), root_widget=True + ) + if profile: + widget.load_profile(profile) + logger.info(f"Created advanced dock area with profile: {profile}") + return widget def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates: diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 05c4f6d9f..1a458abb9 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -29,8 +29,9 @@ from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_profiles from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton @@ -211,10 +212,11 @@ def __init__( name="dock_area", icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), top_label="Get started", - main_label="BEC Dock Area", - description="Highly flexible and customizable dock area application with modular widgets.", - action_button=lambda: self.launch("dock_area"), - show_selector=False, + main_label="BEC Advanced Dock Area", + description="Flexible application for managing modular widgets and user profiles.", + action_button=self._open_dock_area, + show_selector=True, + selector_items=list_profiles("bec"), ) self.available_auto_updates: dict[str, type[AutoUpdates]] = ( @@ -347,7 +349,7 @@ def launch( from bec_widgets.applications import bw_launch with RPCRegister.delayed_broadcast() as rpc_register: - existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea) + existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea) if name is not None: if name in existing_dock_areas: raise ValueError( @@ -384,7 +386,7 @@ def launch( if launch is None: raise ValueError(f"Launch script {launch_script} not found.") - result_widget = launch(name) + result_widget = launch(name, **kwargs) result_widget.resize(result_widget.minimumSizeHint()) # TODO Should we simply use the specified name as title here? result_widget.window().setWindowTitle(f"BEC - {name}") @@ -491,6 +493,17 @@ def _open_auto_update(self): auto_update = None return self.launch("auto_update", auto_update=auto_update) + def _open_dock_area(self): + """ + Open Advanced Dock Area using the selected profile (if any). + """ + tile = self.tiles.get("dock_area") + if tile is None or tile.selector is None: + profile = None + else: + profile = tile.selector.currentText().strip() or None + return self.launch("dock_area", profile=profile) + def _open_widget(self): """ Open a widget from the available widgets. @@ -584,7 +597,10 @@ def closeEvent(self, event): if __name__ == "__main__": import sys + from bec_widgets.utils.colors import apply_theme + app = QApplication(sys.argv) + apply_theme("dark") launcher = LaunchWindow() launcher.show() sys.exit(app.exec()) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 46797a0b6..82cfada82 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -27,7 +27,6 @@ class _WidgetsEnumType(str, enum.Enum): _Widgets = { - "BECDockArea": "BECDockArea", "BECMainWindow": "BECMainWindow", "BECProgressBar": "BECProgressBar", "BECQueue": "BECQueue", @@ -272,298 +271,6 @@ def detach(self): """ -class BECDock(RPCBase): - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. - """ - - @property - @rpc_call - def element_list(self) -> "list[BECWidget]": - """ - Get the widgets in the dock. - - Returns: - widgets(list): The widgets in the dock. - """ - - @property - @rpc_call - def elements(self) -> "dict[str, BECWidget]": - """ - Get the widgets in the dock. - - Returns: - widgets(dict): The widgets in the dock. - """ - - @rpc_call - def new( - self, - widget: "BECWidget | str", - name: "str | None" = None, - row: "int | None" = None, - col: "int" = 0, - rowspan: "int" = 1, - colspan: "int" = 1, - shift: "Literal['down', 'up', 'left', 'right']" = "down", - ) -> "BECWidget": - """ - Add a widget to the dock. - - Args: - widget(QWidget): The widget to add. It can not be BECDock or BECDockArea. - name(str): The name of the widget. - row(int): The row to add the widget to. If None, the widget will be added to the next available row. - col(int): The column to add the widget to. - rowspan(int): The number of rows the widget should span. - colspan(int): The number of columns the widget should span. - shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied. - """ - - @rpc_call - def show(self): - """ - Show the dock. - """ - - @rpc_call - def hide(self): - """ - Hide the dock. - """ - - @rpc_call - def show_title_bar(self): - """ - Hide the title bar of the dock. - """ - - @rpc_call - def set_title(self, title: "str"): - """ - Set the title of the dock. - - Args: - title(str): The title of the dock. - """ - - @rpc_call - def hide_title_bar(self): - """ - Hide the title bar of the dock. - """ - - @rpc_call - def available_widgets(self) -> "list": - """ - List all widgets that can be added to the dock. - - Returns: - list: The list of eligible widgets. - """ - - @rpc_call - def delete(self, widget_name: "str") -> "None": - """ - Remove a widget from the dock. - - Args: - widget_name(str): Delete the widget with the given name. - """ - - @rpc_call - def delete_all(self): - """ - Remove all widgets from the dock. - """ - - @rpc_call - def remove(self): - """ - Remove the dock from the parent dock area. - """ - - @rpc_call - def attach(self): - """ - Attach the dock to the parent dock area. - """ - - @rpc_call - def detach(self): - """ - Detach the dock from the parent dock area. - """ - - -class BECDockArea(RPCBase): - """Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.""" - - @property - @rpc_call - def _rpc_id(self) -> "str": - """ - Get the RPC ID of the widget. - """ - - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. - """ - - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. - """ - - @rpc_call - def new( - self, - name: "str | None" = None, - widget: "str | QWidget | None" = None, - widget_name: "str | None" = None, - position: "Literal['bottom', 'top', 'left', 'right', 'above', 'below']" = "bottom", - relative_to: "BECDock | None" = None, - closable: "bool" = True, - floating: "bool" = False, - row: "int | None" = None, - col: "int" = 0, - rowspan: "int" = 1, - colspan: "int" = 1, - ) -> "BECDock": - """ - Add a dock to the dock area. Dock has QGridLayout as layout manager by default. - - Args: - name(str): The name of the dock to be displayed and for further references. Has to be unique. - widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed. - position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock. - relative_to(BECDock): The dock to which the new dock should be added relative to. - closable(bool): Whether the dock is closable. - floating(bool): Whether the dock is detached after creating. - row(int): The row of the added widget. - col(int): The column of the added widget. - rowspan(int): The rowspan of the added widget. - colspan(int): The colspan of the added widget. - - Returns: - BECDock: The created dock. - """ - - @rpc_call - def show(self): - """ - Show all windows including floating docks. - """ - - @rpc_call - def hide(self): - """ - Hide all windows including floating docks. - """ - - @property - @rpc_call - def panels(self) -> "dict[str, BECDock]": - """ - Get the docks in the dock area. - Returns: - dock_dict(dict): The docks in the dock area. - """ - - @property - @rpc_call - def panel_list(self) -> "list[BECDock]": - """ - Get the docks in the dock area. - - Returns: - list: The docks in the dock area. - """ - - @rpc_call - def delete(self, dock_name: "str"): - """ - Delete a dock by name. - - Args: - dock_name(str): The name of the dock to delete. - """ - - @rpc_call - def delete_all(self) -> "None": - """ - Delete all docks. - """ - - @rpc_call - def remove(self) -> "None": - """ - Remove the dock area. If the dock area is embedded in a BECMainWindow and - is set as the central widget, the main window will be closed. - """ - - @rpc_call - def detach_dock(self, dock_name: "str") -> "BECDock": - """ - Undock a dock from the dock area. - - Args: - dock_name(str): The dock to undock. - - Returns: - BECDock: The undocked dock. - """ - - @rpc_call - def attach_all(self): - """ - Return all floating docks to the dock area. - """ - - @rpc_call - def save_state(self) -> "dict": - """ - Save the state of the dock area. - - Returns: - dict: The state of the dock area. - """ - - @rpc_timeout(None) - @rpc_call - def screenshot(self, file_name: "str | None" = None): - """ - Take a screenshot of the dock area and save it to a file. - """ - - @rpc_call - def restore_state( - self, state: "dict" = None, missing: "Literal['ignore', 'error']" = "ignore", extra="bottom" - ): - """ - Restore the state of the dock area. If no state is provided, the last state is restored. - - Args: - state(dict): The state to restore. - missing(Literal['ignore','error']): What to do if a dock is missing. - extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument. - """ - - class BECMainWindow(RPCBase): @rpc_call def remove(self): diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index ed0dc0356..5cb0fefd1 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -25,11 +25,9 @@ QWidget, ) -from bec_widgets import BECWidget from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh -from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole @@ -366,15 +364,6 @@ def _push_to_console(self, mapping: Dict[str, Any]): def closeEvent(self, event): """Override to handle things when main window is closed.""" - # clean up any widgets that might have custom cleanup - try: - # call cleanup on known containers if present - dock = self._widgets_by_name.get("dock") - if isinstance(dock, BECDockArea): - dock.cleanup() - dock.close() - except Exception: - pass # Ensure the embedded kernel and BEC client are shut down before window teardown self.console.shutdown_kernel() diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 9c820c7a0..6b7ef0c54 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -86,7 +86,6 @@ def __init__( config: ConnectionConfig | None = None, gui_id: str | None = None, object_name: str | None = None, - parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 root_widget: bool = False, **kwargs, ): @@ -98,7 +97,6 @@ def __init__( config(ConnectionConfig, optional): The connection configuration with specific gui id. gui_id(str, optional): The GUI ID. object_name(str, optional): The object name. - parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473 root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object. **kwargs: """ @@ -119,7 +117,6 @@ def __init__( # BEC related connections self.bec_dispatcher = BECDispatcher(client=client) self.client = self.bec_dispatcher.client if client is None else client - self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473 self.rpc_register = RPCRegister() if not self.client in BECConnector.EXIT_HANDLERS: @@ -456,12 +453,8 @@ def on_config_update(self, config: ConnectionConfig | dict) -> None: def remove(self): """Cleanup the BECConnector""" - # If the widget is attached to a dock, remove it from the dock. - # TODO this should be handled by dock and dock are not by BECConnector -> issue created #473 - if self._parent_dock is not None: - self._parent_dock.delete(self.object_name) # If the widget is from Qt, trigger its close method. - elif hasattr(self, "close"): + if hasattr(self, "close"): self.close() # If the widget is neither from a Dock nor from Qt, remove it from the RPC registry. # i.e. Curve Item from Waveform diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index dd94d0c81..5b6bb392e 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -39,7 +39,6 @@ def __init__( theme_update: bool = False, start_busy: bool = False, busy_text: str = "Loading…", - parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 **kwargs, ): """ @@ -58,9 +57,7 @@ def __init__( theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the widget's apply_theme method will be called when the theme changes. """ - super().__init__( - client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs - ) + super().__init__(client=client, config=config, gui_id=gui_id, **kwargs) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") if theme_update: diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 5c223e1ba..a112d26d4 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -104,6 +104,8 @@ class AdvancedDockArea(DockAreaWidget): "print_layout_structure", "mode", "mode.setter", + "save_profile", + "load_profile", ] # Define a signal for mode changes @@ -172,10 +174,6 @@ def __init__( if self._ensure_initial_profile(): self._refresh_workspace_list() - # Sync Developer toggle icon state after initial setup #TODO temporary disable - # dev_action = self.toolbar.components.get_action("developer_mode").action - # dev_action.setChecked(self._editable) - # Apply the requested mode after everything is set up self.mode = mode if self._restore_initial_profile: @@ -319,7 +317,7 @@ def _apply_dock_lock(self, locked: bool) -> None: def _setup_toolbar(self): self.toolbar = ModularToolBar(parent=self) - PLOT_ACTIONS = { + plot_actions = { "waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"), "scatter_waveform": ( ScatterWaveform.ICON_NAME, @@ -331,7 +329,7 @@ def _setup_toolbar(self): "motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"), "heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"), } - DEVICE_ACTIONS = { + device_actions = { "scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"), "positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"), "positioner_box_2D": ( @@ -340,7 +338,7 @@ def _setup_toolbar(self): "PositionerBox2D", ), } - UTIL_ACTIONS = { + util_actions = { "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), "status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), "progress_bar": ( @@ -372,9 +370,9 @@ def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): b.add_action(key) self.toolbar.add_bundle(b) - _build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS) - _build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS) - _build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS) + _build_menu("menu_plots", "Add Plot ", plot_actions) + _build_menu("menu_devices", "Add Device Control ", device_actions) + _build_menu("menu_utils", "Add Utils ", util_actions) # Create flat toolbar bundles for each widget type def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): @@ -398,14 +396,14 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) self.toolbar.add_bundle(bundle) - _build_flat_bundles("plots", PLOT_ACTIONS) - _build_flat_bundles("devices", DEVICE_ACTIONS) - _build_flat_bundles("utils", UTIL_ACTIONS) + _build_flat_bundles("plots", plot_actions) + _build_flat_bundles("devices", device_actions) + _build_flat_bundles("utils", util_actions) # Workspace spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components) spacer = QWidget(parent=self.toolbar.components.toolbar) - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) spacer_bundle.add_action("spacer") self.toolbar.add_bundle(spacer_bundle) @@ -444,16 +442,15 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) bda.add_action("attach_all") bda.add_action("screenshot") bda.add_action("dark_mode") - # bda.add_action("developer_mode") #TODO temporary disable self.toolbar.add_bundle(bda) self._apply_toolbar_layout() # Store mappings on self for use in _hook_toolbar self._ACTION_MAPPINGS = { - "menu_plots": PLOT_ACTIONS, - "menu_devices": DEVICE_ACTIONS, - "menu_utils": UTIL_ACTIONS, + "menu_plots": plot_actions, + "menu_devices": device_actions, + "menu_utils": util_actions, } def _hook_toolbar(self): @@ -699,6 +696,9 @@ def load_profile(self, name: str | None = None): Before switching, persist the current profile to the user copy. Prefer loading the user copy; fall back to the default copy. + + Args: + name (str | None): The name of the profile to load. If None, prompts the user. """ if not name: # Gui fallback if the name is not provided name, ok = QInputDialog.getText( diff --git a/bec_widgets/widgets/containers/auto_update/auto_updates.py b/bec_widgets/widgets/containers/auto_update/auto_updates.py index ed4777269..9f75c7993 100644 --- a/bec_widgets/widgets/containers/auto_update/auto_updates.py +++ b/bec_widgets/widgets/containers/auto_update/auto_updates.py @@ -7,12 +7,12 @@ from bec_lib.messages import ScanStatusMessage from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow +from bec_widgets.widgets.containers.qt_ads import CDockWidget if TYPE_CHECKING: # pragma: no cover from bec_widgets.utils.bec_widget import BECWidget - from bec_widgets.widgets.containers.dock.dock import BECDock from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform @@ -24,7 +24,7 @@ class AutoUpdates(BECMainWindow): - _default_dock: BECDock + _default_dock: CDockWidget | None USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"] RPC = True PLUGIN = False @@ -37,7 +37,12 @@ def __init__( ): super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) - self.dock_area = BECDockArea(parent=self, object_name="dock_area") + self.dock_area = AdvancedDockArea( + parent=self, + object_name="dock_area", + enable_profile_management=False, + restore_initial_profile=False, + ) self.setCentralWidget(self.dock_area) self._auto_update_selected_device: str | None = None @@ -106,9 +111,11 @@ def start_default_dock(self): """ Create a default dock for the auto updates. """ + self.dock_area.delete_all() self.dock_name = "update_dock" - self._default_dock = self.dock_area.new(self.dock_name) - self.current_widget = self._default_dock.new("Waveform") + self.current_widget = self.dock_area.new("Waveform") + docks = self.dock_area.dock_list() + self._default_dock = docks[0] if docks else None @overload def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ... @@ -138,16 +145,18 @@ def set_dock_to_widget( Returns: BECWidget: The widget that was set. """ - if self._default_dock is None or self.current_widget is None: + if self.current_widget is None: logger.warning( f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}" ) self.start_default_dock() assert self.current_widget is not None - if not self.current_widget.__class__.__name__ == widget: - self._default_dock.delete(self.current_widget.object_name) - self.current_widget = self._default_dock.new(widget) + if self.current_widget.__class__.__name__ != widget: + self.dock_area.delete_all() + self.current_widget = self.dock_area.new(widget) + docks = self.dock_area.dock_list() + self._default_dock = docks[0] if docks else None return self.current_widget def get_selected_device( diff --git a/bec_widgets/widgets/containers/dock/__init__.py b/bec_widgets/widgets/containers/dock/__init__.py deleted file mode 100644 index d83dbe5f7..000000000 --- a/bec_widgets/widgets/containers/dock/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .dock import BECDock -from .dock_area import BECDockArea diff --git a/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject b/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject deleted file mode 100644 index e12ce0314..000000000 --- a/bec_widgets/widgets/containers/dock/bec_dock_area.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['dock_area.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py b/bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py deleted file mode 100644 index fb507cc67..000000000 --- a/bec_widgets/widgets/containers/dock/bec_dock_area_plugin.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (C) 2022 The Qt Company Ltd. -# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause - -from qtpy.QtDesigner import QDesignerCustomWidgetInterface -from qtpy.QtWidgets import QWidget - -from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea - -DOM_XML = """ - - - - -""" - - -class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover - def __init__(self): - super().__init__() - self._form_editor = None - - def createWidget(self, parent): - if parent is None: - return QWidget() - t = BECDockArea(parent) - return t - - def domXml(self): - return DOM_XML - - def group(self): - return "BEC Containers" - - def icon(self): - return designer_material_icon(BECDockArea.ICON_NAME) - - def includeFile(self): - return "bec_dock_area" - - def initialize(self, form_editor): - self._form_editor = form_editor - - def isContainer(self): - return False - - def isInitialized(self): - return self._form_editor is not None - - def name(self): - return "BECDockArea" - - def toolTip(self): - return "" - - def whatsThis(self): - return self.toolTip() diff --git a/bec_widgets/widgets/containers/dock/dock.py b/bec_widgets/widgets/containers/dock/dock.py deleted file mode 100644 index 07f81a45c..000000000 --- a/bec_widgets/widgets/containers/dock/dock.py +++ /dev/null @@ -1,440 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Literal, Optional, cast - -from bec_lib.logger import bec_logger -from pydantic import Field -from pyqtgraph.dockarea import Dock, DockLabel -from qtpy import QtCore, QtGui - -from bec_widgets.cli.client_utils import IGNORE_WIDGETS -from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler -from bec_widgets.utils import ConnectionConfig, GridLayoutManager -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.container_utils import WidgetContainerUtils -from bec_widgets.utils.error_popups import SafeSlot - -logger = bec_logger.logger - -if TYPE_CHECKING: # pragma: no cover - from qtpy.QtWidgets import QWidget - - from bec_widgets.widgets.containers.dock.dock_area import BECDockArea - - -class DockConfig(ConnectionConfig): - widgets: dict[str, Any] = Field({}, description="The widgets in the dock.") - position: Literal["bottom", "top", "left", "right", "above", "below"] = Field( - "bottom", description="The position of the dock." - ) - parent_dock_area: Optional[str] | None = Field( - None, description="The GUI ID of parent dock area of the dock." - ) - - -class CustomDockLabel(DockLabel): - def __init__(self, text: str, closable: bool = True): - super().__init__(text, closable) - if closable: - red_icon = QtGui.QIcon() - pixmap = QtGui.QPixmap(32, 32) - pixmap.fill(QtCore.Qt.GlobalColor.red) - painter = QtGui.QPainter(pixmap) - pen = QtGui.QPen(QtCore.Qt.GlobalColor.white) - pen.setWidth(2) - painter.setPen(pen) - painter.drawLine(8, 8, 24, 24) - painter.drawLine(24, 8, 8, 24) - painter.end() - red_icon.addPixmap(pixmap) - - self.closeButton.setIcon(red_icon) - - def updateStyle(self): - r = "3px" - if self.dim: - fg = "#aaa" - bg = "#44a" - border = "#339" - else: - fg = "#fff" - bg = "#3f4042" - border = "#3f4042" - - if self.orientation == "vertical": - self.vStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: 0px; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: %s; - border-width: 0px; - border-right: 2px solid %s; - padding-top: 3px; - padding-bottom: 3px; - font-size: %s; - }""" % ( - bg, - fg, - r, - r, - border, - self.fontSize, - ) - self.setStyleSheet(self.vStyle) - else: - self.hStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: %s; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - border-width: 0px; - border-bottom: 2px solid %s; - padding-left: 3px; - padding-right: 3px; - font-size: %s; - }""" % ( - bg, - fg, - r, - r, - border, - self.fontSize, - ) - self.setStyleSheet(self.hStyle) - - -class BECDock(BECWidget, Dock): - ICON_NAME = "widgets" - USER_ACCESS = [ - "_config_dict", - "element_list", - "elements", - "new", - "show", - "hide", - "show_title_bar", - "set_title", - "hide_title_bar", - "available_widgets", - "delete", - "delete_all", - "remove", - "attach", - "detach", - ] - - def __init__( - self, - parent: QWidget | None = None, - parent_dock_area: BECDockArea | None = None, - config: DockConfig | None = None, - name: str | None = None, - object_name: str | None = None, - client=None, - gui_id: str | None = None, - closable: bool = True, - **kwargs, - ) -> None: - - if config is None: - config = DockConfig( - widget_class=self.__class__.__name__, - parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None, - ) - else: - if isinstance(config, dict): - config = DockConfig(**config) - self.config = config - label = CustomDockLabel(text=name, closable=closable) - super().__init__( - parent=parent_dock_area, - name=name, - object_name=object_name, - client=client, - gui_id=gui_id, - config=config, - label=label, - **kwargs, - ) - - self.parent_dock_area = parent_dock_area - # Layout Manager - self.layout_manager = GridLayoutManager(self.layout) - - def dropEvent(self, event): - source = event.source() - old_area = source.area - self.setOrientation("horizontal", force=True) - super().dropEvent(event) - if old_area in self.orig_area.tempAreas and old_area != self.orig_area: - self.orig_area.removeTempArea(old_area) - old_area.window().deleteLater() - - def float(self): - """ - Float the dock. - Overwrites the default pyqtgraph dock float. - """ - - # need to check if the dock is temporary and if it is the only dock in the area - # fixes bug in pyqtgraph detaching - if self.area.temporary == True and len(self.area.docks) <= 1: - return - elif self.area.temporary == True and len(self.area.docks) > 1: - self.area.docks.pop(self.name(), None) - super().float() - else: - super().float() - - @property - def elements(self) -> dict[str, BECWidget]: - """ - Get the widgets in the dock. - - Returns: - widgets(dict): The widgets in the dock. - """ - # pylint: disable=protected-access - return dict((widget.object_name, widget) for widget in self.element_list) - - @property - def element_list(self) -> list[BECWidget]: - """ - Get the widgets in the dock. - - Returns: - widgets(list): The widgets in the dock. - """ - return self.widgets - - def hide_title_bar(self): - """ - Hide the title bar of the dock. - """ - # self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation - self.label.hide() - self.labelHidden = True - - def show(self): - """ - Show the dock. - """ - super().show() - self.show_title_bar() - - def hide(self): - """ - Hide the dock. - """ - self.hide_title_bar() - super().hide() - - def show_title_bar(self): - """ - Hide the title bar of the dock. - """ - # self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation - self.label.show() - self.labelHidden = False - - def set_title(self, title: str): - """ - Set the title of the dock. - - Args: - title(str): The title of the dock. - """ - self.orig_area.docks[title] = self.orig_area.docks.pop(self.name()) - self.setTitle(title) - - def get_widgets_positions(self) -> dict: - """ - Get the positions of the widgets in the dock. - - Returns: - dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget} - """ - return self.layout_manager.get_widgets_positions() - - def available_widgets( - self, - ) -> list: # TODO can be moved to some util mixin like container class for rpc widgets - """ - List all widgets that can be added to the dock. - - Returns: - list: The list of eligible widgets. - """ - return list(widget_handler.widget_classes.keys()) - - def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]: - if (docks := self.parent_dock_area.panel_list) is None: - return [] - widgets = [] - for dock in docks: - widgets.extend(dock.elements.keys()) - return widgets - - @SafeSlot(popup_error=True) - def new( - self, - widget: BECWidget | str, - name: str | None = None, - row: int | None = None, - col: int = 0, - rowspan: int = 1, - colspan: int = 1, - shift: Literal["down", "up", "left", "right"] = "down", - ) -> BECWidget: - """ - Add a widget to the dock. - - Args: - widget(QWidget): The widget to add. It can not be BECDock or BECDockArea. - name(str): The name of the widget. - row(int): The row to add the widget to. If None, the widget will be added to the next available row. - col(int): The column to add the widget to. - rowspan(int): The number of rows the widget should span. - colspan(int): The number of columns the widget should span. - shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied. - """ - if name is not None: - WidgetContainerUtils.raise_for_invalid_name(name, container=self) - - if row is None: - row = self.layout.rowCount() - - if self.layout_manager.is_position_occupied(row, col): - self.layout_manager.shift_widgets(shift, start_row=row) - - # Check that Widget is not BECDock or BECDockArea - widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__ - if widget_class_name in IGNORE_WIDGETS: - raise ValueError(f"Widget {widget} can not be added to dock.") - - if isinstance(widget, str): - widget = cast( - BECWidget, - widget_handler.create_widget( - widget_type=widget, object_name=name, parent_dock=self, parent=self - ), - ) - else: - widget.object_name = name - - self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan) - if hasattr(widget, "config"): - widget.config.gui_id = widget.gui_id - self.config.widgets[widget.object_name] = widget.config - return widget - - def move_widget(self, widget: QWidget, new_row: int, new_col: int): - """ - Move a widget to a new position in the layout. - - Args: - widget(QWidget): The widget to move. - new_row(int): The new row to move the widget to. - new_col(int): The new column to move the widget to. - """ - self.layout_manager.move_widget(widget, new_row, new_col) - - def attach(self): - """ - Attach the dock to the parent dock area. - """ - self.parent_dock_area.remove_temp_area(self.area) - - def detach(self): - """ - Detach the dock from the parent dock area. - """ - self.float() - - def remove(self): - """ - Remove the dock from the parent dock area. - """ - self.parent_dock_area.delete(self.object_name) - - def delete(self, widget_name: str) -> None: - """ - Remove a widget from the dock. - - Args: - widget_name(str): Delete the widget with the given name. - """ - # pylint: disable=protected-access - widgets = [widget for widget in self.widgets if widget.object_name == widget_name] - if len(widgets) == 0: - logger.warning( - f"Widget with name {widget_name} not found in dock {self.name()}. " - f"Checking if gui_id was passed as widget_name." - ) - # Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name - widget = self.rpc_register.get_rpc_by_id(widget_name) - if widget is None: - logger.warning( - f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}" - ) - return - else: - widget = widgets[0] - self.layout.removeWidget(widget) - self.config.widgets.pop(widget.object_name, None) - if widget in self.widgets: - self.widgets.remove(widget) - widget.close() - widget.deleteLater() - - def delete_all(self): - """ - Remove all widgets from the dock. - """ - for widget in self.widgets: - self.delete(widget.object_name) - - def cleanup(self): - """ - Clean up the dock, including all its widgets. - """ - # # FIXME Cleanup might be called twice - try: - logger.info(f"Cleaning up dock {self.name()}") - self.label.close() - self.label.deleteLater() - except Exception as e: - logger.error(f"Error while closing dock label: {e}") - - # Remove the dock from the parent dock area - if self.parent_dock_area: - self.parent_dock_area.dock_area.docks.pop(self.name(), None) - self.parent_dock_area.config.docks.pop(self.name(), None) - self.delete_all() - self.widgets.clear() - super().cleanup() - self.deleteLater() - - def close(self): - """ - Close the dock area and cleanup. - Has to be implemented to overwrite pyqtgraph event accept in Container close. - """ - self.cleanup() - super().close() - - -if __name__ == "__main__": # pragma: no cover - import sys - - from qtpy.QtWidgets import QApplication - - app = QApplication([]) - dock = BECDock(name="dock") - dock.show() - app.exec_() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py deleted file mode 100644 index 50210b24e..000000000 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ /dev/null @@ -1,633 +0,0 @@ -from __future__ import annotations - -from typing import Literal, Optional -from weakref import WeakValueDictionary - -from bec_lib.logger import bec_logger -from pydantic import Field -from pyqtgraph.dockarea.DockArea import DockArea -from qtpy.QtCore import QSize, Qt -from qtpy.QtGui import QPainter, QPaintEvent -from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget - -from bec_widgets.cli.rpc.rpc_register import RPCRegister -from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.name_utils import pascal_to_snake -from bec_widgets.utils.toolbars.actions import ( - ExpandableMenuAction, - MaterialIconAction, - WidgetAction, -) -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.utils.widget_io import WidgetHierarchy -from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig -from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow -from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox -from bec_widgets.widgets.control.scan_control.scan_control import ScanControl -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor -from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap -from bec_widgets.widgets.plots.image.image import Image -from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap -from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform -from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform -from bec_widgets.widgets.plots.waveform.waveform import Waveform -from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar -from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue -from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox -from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel -from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton - -logger = bec_logger.logger - - -class DockAreaConfig(ConnectionConfig): - docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.") - docks_state: Optional[dict] = Field( - None, description="The state of the docks in the dock area." - ) - - -class BECDockArea(BECWidget, QWidget): - """ - Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout. - """ - - PLUGIN = True - USER_ACCESS = [ - "_rpc_id", - "_config_dict", - "_get_all_rpc", - "new", - "show", - "hide", - "panels", - "panel_list", - "delete", - "delete_all", - "remove", - "detach_dock", - "attach_all", - "save_state", - "screenshot", - "restore_state", - ] - - def __init__( - self, - parent: QWidget | None = None, - config: DockAreaConfig | None = None, - client=None, - gui_id: str = None, - object_name: str = None, - **kwargs, - ) -> None: - if config is None: - config = DockAreaConfig(widget_class=self.__class__.__name__) - else: - if isinstance(config, dict): - config = DockAreaConfig(**config) - self.config = config - super().__init__( - parent=parent, - object_name=object_name, - client=client, - gui_id=gui_id, - config=config, - **kwargs, - ) - self._parent = parent # TODO probably not needed - self.layout = QVBoxLayout(self) - self.layout.setSpacing(5) - self.layout.setContentsMargins(0, 0, 0, 0) - - self._instructions_visible = True - - self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) - self.dock_area = DockArea(parent=self) - self.toolbar = ModularToolBar(parent=self) - self._setup_toolbar() - - self.layout.addWidget(self.toolbar) - self.layout.addWidget(self.dock_area) - - self._hook_toolbar() - self.toolbar.show_bundles( - ["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"] - ) - - def minimumSizeHint(self): - return QSize(800, 600) - - def _setup_toolbar(self): - - # Add plot menu - self.toolbar.components.add_safe( - "menu_plots", - ExpandableMenuAction( - label="Add Plot ", - actions={ - "waveform": MaterialIconAction( - icon_name=Waveform.ICON_NAME, - tooltip="Add Waveform", - filled=True, - parent=self, - ), - "scatter_waveform": MaterialIconAction( - icon_name=ScatterWaveform.ICON_NAME, - tooltip="Add Scatter Waveform", - filled=True, - parent=self, - ), - "multi_waveform": MaterialIconAction( - icon_name=MultiWaveform.ICON_NAME, - tooltip="Add Multi Waveform", - filled=True, - parent=self, - ), - "image": MaterialIconAction( - icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self - ), - "motor_map": MaterialIconAction( - icon_name=MotorMap.ICON_NAME, - tooltip="Add Motor Map", - filled=True, - parent=self, - ), - "heatmap": MaterialIconAction( - icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self - ), - }, - ), - ) - - bundle = ToolbarBundle("menu_plots", self.toolbar.components) - bundle.add_action("menu_plots") - self.toolbar.add_bundle(bundle) - - # Add control menu - self.toolbar.components.add_safe( - "menu_devices", - ExpandableMenuAction( - label="Add Device Control ", - actions={ - "scan_control": MaterialIconAction( - icon_name=ScanControl.ICON_NAME, - tooltip="Add Scan Control", - filled=True, - parent=self, - ), - "positioner_box": MaterialIconAction( - icon_name=PositionerBox.ICON_NAME, - tooltip="Add Device Box", - filled=True, - parent=self, - ), - }, - ), - ) - bundle = ToolbarBundle("menu_devices", self.toolbar.components) - bundle.add_action("menu_devices") - self.toolbar.add_bundle(bundle) - - # Add utils menu - self.toolbar.components.add_safe( - "menu_utils", - ExpandableMenuAction( - label="Add Utils ", - actions={ - "queue": MaterialIconAction( - icon_name=BECQueue.ICON_NAME, - tooltip="Add Scan Queue", - filled=True, - parent=self, - ), - "vs_code": MaterialIconAction( - icon_name=VSCodeEditor.ICON_NAME, - tooltip="Add VS Code", - filled=True, - parent=self, - ), - "status": MaterialIconAction( - icon_name=BECStatusBox.ICON_NAME, - tooltip="Add BEC Status Box", - filled=True, - parent=self, - ), - "progress_bar": MaterialIconAction( - icon_name=RingProgressBar.ICON_NAME, - tooltip="Add Circular ProgressBar", - filled=True, - parent=self, - ), - # FIXME temporarily disabled -> issue #644 - "log_panel": MaterialIconAction( - icon_name=LogPanel.ICON_NAME, - tooltip="Add LogPanel - Disabled", - filled=True, - parent=self, - ), - "sbb_monitor": MaterialIconAction( - icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self - ), - }, - ), - ) - bundle = ToolbarBundle("menu_utils", self.toolbar.components) - bundle.add_action("menu_utils") - self.toolbar.add_bundle(bundle) - - ########## Dock Actions ########## - spacer = QWidget(parent=self) - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) - - self.toolbar.components.add_safe( - "dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False) - ) - - bundle = ToolbarBundle("dark_mode", self.toolbar.components) - bundle.add_action("spacer") - bundle.add_action("dark_mode") - self.toolbar.add_bundle(bundle) - - self.toolbar.components.add_safe( - "attach_all", - MaterialIconAction( - icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self - ), - ) - - self.toolbar.components.add_safe( - "save_state", - MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self), - ) - self.toolbar.components.add_safe( - "restore_state", - MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self), - ) - self.toolbar.components.add_safe( - "screenshot", - MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self), - ) - - bundle = ToolbarBundle("dock_actions", self.toolbar.components) - bundle.add_action("attach_all") - bundle.add_action("save_state") - bundle.add_action("restore_state") - bundle.add_action("screenshot") - self.toolbar.add_bundle(bundle) - - def _hook_toolbar(self): - menu_plots = self.toolbar.components.get_action("menu_plots") - menu_devices = self.toolbar.components.get_action("menu_devices") - menu_utils = self.toolbar.components.get_action("menu_utils") - - menu_plots.actions["waveform"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="Waveform") - ) - - menu_plots.actions["scatter_waveform"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform") - ) - menu_plots.actions["multi_waveform"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform") - ) - menu_plots.actions["image"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="Image") - ) - menu_plots.actions["motor_map"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="MotorMap") - ) - menu_plots.actions["heatmap"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="Heatmap") - ) - - # Menu Devices - menu_devices.actions["scan_control"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="ScanControl") - ) - menu_devices.actions["positioner_box"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="PositionerBox") - ) - - # Menu Utils - menu_utils.actions["queue"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="BECQueue") - ) - menu_utils.actions["status"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox") - ) - menu_utils.actions["vs_code"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor") - ) - menu_utils.actions["progress_bar"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar") - ) - # FIXME temporarily disabled -> issue #644 - menu_utils.actions["log_panel"].action.setEnabled(False) - - menu_utils.actions["sbb_monitor"].action.triggered.connect( - lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor") - ) - - # Icons - self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) - self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state) - self.toolbar.components.get_action("restore_state").action.triggered.connect( - self.restore_state - ) - self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) - - @SafeSlot() - def _create_widget_from_toolbar(self, widget_name: str) -> None: - # Run with RPC broadcast to namespace of all widgets - with RPCRegister.delayed_broadcast(): - name = pascal_to_snake(widget_name) - dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys()) - self.new(name=dock_name, widget=widget_name) - - def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions - super().paintEvent(event) - if self._instructions_visible: - painter = QPainter(self) - painter.drawText( - self.rect(), - Qt.AlignCenter, - "Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar", - ) - - @property - def panels(self) -> dict[str, BECDock]: - """ - Get the docks in the dock area. - Returns: - dock_dict(dict): The docks in the dock area. - """ - return dict(self.dock_area.docks) - - @panels.setter - def panels(self, value: dict[str, BECDock]): - self.dock_area.docks = WeakValueDictionary(value) # This can not work can it? - - @property - def panel_list(self) -> list[BECDock]: - """ - Get the docks in the dock area. - - Returns: - list: The docks in the dock area. - """ - return list(self.dock_area.docks.values()) - - @property - def temp_areas(self) -> list: - """ - Get the temporary areas in the dock area. - - Returns: - list: The temporary areas in the dock area. - """ - return list(map(str, self.dock_area.tempAreas)) - - @temp_areas.setter - def temp_areas(self, value: list): - self.dock_area.tempAreas = list(map(str, value)) - - @SafeSlot() - def restore_state( - self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom" - ): - """ - Restore the state of the dock area. If no state is provided, the last state is restored. - - Args: - state(dict): The state to restore. - missing(Literal['ignore','error']): What to do if a dock is missing. - extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument. - """ - if state is None: - state = self.config.docks_state - if state is None: - return - self.dock_area.restoreState(state, missing=missing, extra=extra) - - @SafeSlot() - def save_state(self) -> dict: - """ - Save the state of the dock area. - - Returns: - dict: The state of the dock area. - """ - last_state = self.dock_area.saveState() - self.config.docks_state = last_state - return last_state - - @SafeSlot(popup_error=True) - def new( - self, - name: str | None = None, - widget: str | QWidget | None = None, - widget_name: str | None = None, - position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom", - relative_to: BECDock | None = None, - closable: bool = True, - floating: bool = False, - row: int | None = None, - col: int = 0, - rowspan: int = 1, - colspan: int = 1, - ) -> BECDock: - """ - Add a dock to the dock area. Dock has QGridLayout as layout manager by default. - - Args: - name(str): The name of the dock to be displayed and for further references. Has to be unique. - widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed. - position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock. - relative_to(BECDock): The dock to which the new dock should be added relative to. - closable(bool): Whether the dock is closable. - floating(bool): Whether the dock is detached after creating. - row(int): The row of the added widget. - col(int): The column of the added widget. - rowspan(int): The rowspan of the added widget. - colspan(int): The colspan of the added widget. - - Returns: - BECDock: The created dock. - """ - dock_names = [ - dock.object_name for dock in self.panel_list - ] # pylint: disable=protected-access - if name is not None: # Name is provided - if name in dock_names: - raise ValueError( - f"Name {name} must be unique for docks, but already exists in DockArea " - f"with name: {self.object_name} and id {self.gui_id}." - ) - WidgetContainerUtils.raise_for_invalid_name(name, container=self) - - else: # Name is not provided - name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names) - - dock = BECDock( - parent=self, - name=name, # this is dock name pyqtgraph property, this is displayed on label - object_name=name, # this is a real qt object name passed to BECConnector - parent_dock_area=self, - closable=closable, - ) - dock.config.position = position - self.config.docks[dock.name()] = dock.config - # The dock.name is equal to the name passed to BECDock - self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to) - - if len(self.dock_area.docks) <= 1: - dock.hide_title_bar() - elif len(self.dock_area.docks) > 1: - for dock in self.dock_area.docks.values(): - dock.show_title_bar() - - if widget is not None: - # Check if widget name exists. - dock.new( - widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan - ) - if ( - self._instructions_visible - ): # TODO still decide how initial instructions should be handled - self._instructions_visible = False - self.update() - if floating: - dock.detach() - return dock - - def detach_dock(self, dock_name: str) -> BECDock: - """ - Undock a dock from the dock area. - - Args: - dock_name(str): The dock to undock. - - Returns: - BECDock: The undocked dock. - """ - dock = self.dock_area.docks[dock_name] - dock.detach() - return dock - - @SafeSlot() - def attach_all(self): - """ - Return all floating docks to the dock area. - """ - while self.dock_area.tempAreas: - for temp_area in self.dock_area.tempAreas: - self.remove_temp_area(temp_area) - - def remove_temp_area(self, area): - """ - Remove a temporary area from the dock area. - This is a patched method of pyqtgraph's removeTempArea - """ - if area not in self.dock_area.tempAreas: - # FIXME add some context for the logging, I am not sure which object is passed. - # It looks like a pyqtgraph.DockArea - logger.info(f"Attempted to remove dock_area, but was not floating.") - return - self.dock_area.tempAreas.remove(area) - area.window().close() - area.window().deleteLater() - - def cleanup(self): - """ - Cleanup the dock area. - """ - self.delete_all() - self.dark_mode_button.close() - self.dark_mode_button.deleteLater() - super().cleanup() - - def show(self): - """Show all windows including floating docks.""" - super().show() - for docks in self.panels.values(): - if docks.window() is self: - # avoid recursion - continue - docks.window().show() - - def hide(self): - """Hide all windows including floating docks.""" - super().hide() - for docks in self.panels.values(): - if docks.window() is self: - # avoid recursion - continue - docks.window().hide() - - def delete_all(self) -> None: - """ - Delete all docks. - """ - self.attach_all() - for dock_name in self.panels.keys(): - self.delete(dock_name) - - def delete(self, dock_name: str): - """ - Delete a dock by name. - - Args: - dock_name(str): The name of the dock to delete. - """ - dock = self.dock_area.docks.pop(dock_name, None) - self.config.docks.pop(dock_name, None) - if dock: - dock.close() - dock.deleteLater() - if len(self.dock_area.docks) <= 1: - for dock in self.dock_area.docks.values(): - dock.hide_title_bar() - else: - raise ValueError(f"Dock with name {dock_name} does not exist.") - # self._broadcast_update() - - def remove(self) -> None: - """ - Remove the dock area. If the dock area is embedded in a BECMainWindow and - is set as the central widget, the main window will be closed. - """ - parent = self.parent() - if isinstance(parent, BECMainWindow): - central_widget = parent.centralWidget() - if central_widget is self: - # Closing the parent will also close the dock area - parent.close() - return - - self.close() - - -if __name__ == "__main__": # pragma: no cover - - import sys - - from bec_widgets.utils.colors import apply_theme - - app = QApplication([]) - apply_theme("dark") - dock_area = BECDockArea() - dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton") - dock_1.new(widget="DarkModeButton") - # dock_1 = dock_area.new(name="dock_0", widget="Waveform") - dock_area.new(widget="DarkModeButton") - dock_area.show() - dock_area.setGeometry(100, 100, 800, 600) - app.topLevelWidgets() - WidgetHierarchy.print_becconnector_hierarchy_from_app() - app.exec_() - sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/dock/register_bec_dock_area.py b/bec_widgets/widgets/containers/dock/register_bec_dock_area.py deleted file mode 100644 index 2c33f79d9..000000000 --- a/bec_widgets/widgets/containers/dock/register_bec_dock_area.py +++ /dev/null @@ -1,15 +0,0 @@ -def main(): # pragma: no cover - from qtpy import PYSIDE6 - - if not PYSIDE6: - print("PYSIDE6 is not available in the environment. Cannot patch designer.") - return - from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - - from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin - - QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py index 392c0a6dc..d783a215e 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py @@ -7,7 +7,6 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.ophyd_kind_util import Kind -from bec_widgets.widgets.containers.dock.dock import BECDock from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel @@ -21,12 +20,11 @@ def __init__( config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, - parent_dock: BECDock | None = None, **kwargs, ): """A widget to display all the signals from a given device, and allow getting a fresh reading.""" - super().__init__(client, config, gui_id, theme_update, parent_dock, **kwargs) + super().__init__(client, config, gui_id, theme_update, **kwargs) self.get_bec_shortcuts() self._layout = QVBoxLayout() self.setLayout(self._layout) diff --git a/tests/end-2-end/conftest.py b/tests/end-2-end/conftest.py index 7b35c984e..dc4a123d1 100644 --- a/tests/end-2-end/conftest.py +++ b/tests/end-2-end/conftest.py @@ -47,6 +47,10 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib): try: gui.start(wait=True) qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000) + gui.bec.delete_all() # ensure clean state + qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) yield gui finally: + gui.bec.delete_all() # ensure clean state + qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) gui.kill_server() diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index a6d00d731..180d4806c 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -19,56 +19,33 @@ def check_dock_area_registered(): qtbot.waitUntil(check_dock_area_registered, timeout=5000) assert hasattr(gui, "cool_dock_area") - dock = dock_area.new("dock_0") + widget = dock_area.new("Waveform", object_name="cool_waveform") - def check_dock_registered(): - return dock._gui_id in gui._server_registry + def check_widget_registered(): + return widget._gui_id in gui._server_registry - qtbot.waitUntil(check_dock_registered, timeout=5000) - assert hasattr(gui.cool_dock_area, "dock_0") + qtbot.waitUntil(check_widget_registered, timeout=5000) + assert hasattr(gui.cool_dock_area, widget.object_name) def test_rpc_add_dock_with_plots_e2e(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj # BEC client shortcuts - dock = gui.bec - client = bec_client_lib - dev = client.device_manager.devices - scans = client.scans - queue = client.queue - - # Create 3 docks - d0 = dock.new("dock_0") - d1 = dock.new("dock_1") - d2 = dock.new("dock_2") - - # Check that callback for dock_registry is done - def check_docks_registered(): - return all( - [gui_id in gui._server_registry for gui_id in [d0._gui_id, d1._gui_id, d2._gui_id]] - ) - - # Waii until docks are registered - qtbot.waitUntil(check_docks_registered, timeout=5000) - assert len(dock.panels) == 3 - assert hasattr(gui.bec, "dock_0") + dock_area = gui.bec # Add 3 figures with some widgets - wf = d0.new("Waveform") - im = d1.new("Image") - mm = d2.new("MotorMap") + wf = dock_area.new("Waveform") + im = dock_area.new("Image") + mm = dock_area.new("MotorMap") - def check_figs_registered(): + def check_widgets_registered(): return all( - [gui_id in gui._server_registry for gui_id in [wf._gui_id, im._gui_id, mm._gui_id]] + gui_id in gui._server_registry for gui_id in [wf._gui_id, im._gui_id, mm._gui_id] ) - qtbot.waitUntil(check_figs_registered, timeout=5000) - - assert len(d0.element_list) == 1 - assert len(d1.element_list) == 1 - assert len(d2.element_list) == 1 + qtbot.waitUntil(check_widgets_registered, timeout=5000) + assert len(dock_area.widget_list()) == 3 assert wf.__class__.__name__ == "RPCReference" assert wf.__class__ == RPCReference @@ -94,48 +71,46 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj dock_area = gui.bec - d0 = dock_area.new("dock_0") - d1 = dock_area.new("dock_1") - d2 = dock_area.new("dock_2") + w0 = dock_area.new("Waveform") + w1 = dock_area.new("Waveform") + w2 = dock_area.new("Waveform") - assert hasattr(gui.bec, "dock_0") - assert hasattr(gui.bec, "dock_1") - assert hasattr(gui.bec, "dock_2") - assert len(gui.bec.panels) == 3 + assert hasattr(gui.bec, "Waveform") + assert hasattr(gui.bec, "Waveform_0") + assert hasattr(gui.bec, "Waveform_1") + assert len(gui.bec.widget_list()) == 3 - d0.detach() - dock_area.detach_dock("dock_2") - # How can we properly check that the dock is detached? - assert len(gui.bec.panels) == 3 + w0.detach() + w2.detach() + assert len(gui.bec.widget_list()) == 3 - d0.attach() - assert len(gui.bec.panels) == 3 + w0.attach() + w2.attach() + assert len(gui.bec.widget_list()) == 3 - gui_id = d2._gui_id + gui_id = w2._gui_id def wait_for_dock_removed(): return gui_id not in gui._ipython_registry - d2.remove() + w2.remove() qtbot.waitUntil(wait_for_dock_removed, timeout=5000) - assert len(gui.bec.panels) == 2 + assert len(gui.bec.widget_list()) == 2 - ids = [widget._gui_id for widget in dock_area.panel_list] + dock_area.delete_all() - def wait_for_docks_removed(): - return all(widget_id not in gui._ipython_registry for widget_id in ids) + def wait_for_all_docks_deleted(): + return len(gui.bec.widget_list()) == 0 - dock_area.delete_all() - qtbot.waitUntil(wait_for_docks_removed, timeout=5000) - assert len(gui.bec.panels) == 0 + qtbot.waitUntil(wait_for_all_docks_deleted, timeout=5000) + assert len(gui.bec.widget_list()) == 0 def test_ring_bar(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj dock_area = gui.bec - d0 = dock_area.new("dock_0") - bar = d0.new("RingProgressBar") + bar = dock_area.new("RingProgressBar") assert bar.__class__.__name__ == "RPCReference" assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar" @@ -147,14 +122,16 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot): assert gui.windows["bec"] is gui.bec mw = gui.bec assert mw.__class__.__name__ == "RPCReference" - assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "BECDockArea" + assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "AdvancedDockArea" xw = gui.new("X") + xw.delete_all() assert xw.__class__.__name__ == "RPCReference" - assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea" + assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "AdvancedDockArea" assert len(gui.windows) == 2 assert gui._gui_is_alive() + qtbot.wait(500) gui.kill_server() assert not gui._gui_is_alive() gui.start(wait=True) @@ -173,17 +150,7 @@ def wait_for_gui_started(): # communication should work, main dock area should have same id and be visible yw = gui.new("Y") + yw.delete_all() assert len(gui.windows) == 2 yw.remove() assert len(gui.windows) == 1 # only bec is left - - -def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot): - gui = connected_client_gui_obj - - gui.bec.new("test") - qtbot.waitUntil(lambda: len(gui.bec.panels) == 1) # test - qtbot.wait(500) - with pytest.raises(ValueError): - gui.bec.new("test") - # time.sleep(0.1) diff --git a/tests/end-2-end/test_bec_gui_ipython.py b/tests/end-2-end/test_bec_gui_ipython.py index 8ec352825..84493af75 100644 --- a/tests/end-2-end/test_bec_gui_ipython.py +++ b/tests/end-2-end/test_bec_gui_ipython.py @@ -22,4 +22,4 @@ def test_ipython_tab_completion(bec_ipython_shell): _, completer = bec_ipython_shell assert "gui.bec" in completer.all_completions("gui.") assert "gui.bec.new" in completer.all_completions("gui.bec.") - assert "gui.bec.panels" in completer.all_completions("gui.bec.pan") + assert "gui.bec.widget_list" in completer.all_completions("gui.bec.widget_") diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index ebed9f250..f453f6273 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -11,9 +11,9 @@ def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") c1 = wf.plot(x=[1, 2, 3], y=[1, 2, 3]) c1.set_color("red") @@ -26,13 +26,13 @@ def test_rpc_waveform1d_custom_curve(qtbot, connected_client_gui_obj): def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec - wf = dock.new("wf_dock").new("Waveform") - im = dock.new("im_dock").new("Image") - mm = dock.new("mm_dock").new("MotorMap") - sw = dock.new("sw_dock").new("ScatterWaveform") - mw = dock.new("mw_dock").new("MultiWaveform") + wf = dock_area.new("Waveform") + im = dock_area.new("Image") + mm = dock_area.new("MotorMap") + sw = dock_area.new("ScatterWaveform") + mw = dock_area.new("MultiWaveform") c1 = wf.plot(x_name="samx", y_name="bpm4i") # Adding custom curves, removing one and adding it again should not crash @@ -42,7 +42,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): c3 = wf.plot(y=[1, 2, 3], x=[1, 2, 3]) assert c3.object_name == "Curve_0" - im_item = im.image(monitor="eiger") + im.image(monitor="eiger") mm.map(x_name="samx", y_name="samy") sw.plot(x_name="samx", y_name="samy", z_name="bpm4i") assert sw.main_curve.object_name == "bpm4i_bpm4i" @@ -53,7 +53,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): # Adding multiple custom curves sho # Checking if classes are correctly initialised - assert len(dock.panel_list) == 5 + assert len(dock_area.widget_list()) == 5 assert wf.__class__.__name__ == "RPCReference" assert wf.__class__ == RPCReference assert gui._ipython_registry[wf._gui_id].__class__ == Waveform @@ -84,14 +84,14 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans queue = client.queue - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") # add 3 different curves to track wf.plot(x_name="samx", y_name="bpm4i") @@ -125,19 +125,18 @@ def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): @pytest.mark.timeout(100) def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans - queue = client.queue # Test add dev.waveform.sim.select_model("GaussianModel") dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300} dev.waveform.async_update.set("add").wait() dev.waveform.waveform_shape.set(10000).wait() - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") curve = wf.plot(y_name="waveform") status = scans.line_scan(dev.samx, -5, 5, steps=5, exp_time=0.05, relative=False) @@ -163,14 +162,13 @@ def _wait_for_scan_in_history(): def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans - queue = client.queue - im = dock.new("im_dock").new("Image") + im = dock_area.new("Image") im.image(monitor="eiger") status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False) @@ -191,8 +189,9 @@ def test_rpc_motor_map(qtbot, bec_client_lib, connected_client_gui_obj): dev = client.device_manager.devices scans = client.scans - dock = gui.bec - motor_map = dock.new("mm_dock").new("MotorMap") + dock_area = gui.bec + + motor_map = dock_area.new("MotorMap") motor_map.map(x_name="samx", y_name="samy") initial_pos_x = dev.samx.read()["samx"]["value"] @@ -221,8 +220,9 @@ def test_dap_rpc(qtbot, bec_client_lib, connected_client_gui_obj): dev = client.device_manager.devices scans = client.scans - dock = gui.bec - wf = dock.new("wf_dock").new("Waveform") + dock_area = gui.bec + + wf = dock_area.new("Waveform") wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel") dev.bpm4i.sim.select_model("GaussianModel") @@ -262,8 +262,9 @@ def test_waveform_passing_device(qtbot, bec_client_lib, connected_client_gui_obj dev = client.device_manager.devices scans = client.scans - dock = gui.bec - wf = dock.new("wf_dock").new("Waveform") + dock_area = gui.bec + + wf = dock_area.new("Waveform") c1 = wf.plot( y_name=dev.samx, y_entry=dev.samx.setpoint ) # using setpoint to not use readback signal @@ -303,13 +304,13 @@ def test_rpc_waveform_history_curve( Note: Parameterization prevents adding the same logical curve twice (which would collide on label). """ gui = connected_client_gui_obj - dock = gui.bec + dock_area = gui.bec client = bec_client_lib dev = client.device_manager.devices scans = client.scans queue = client.queue - wf = dock.new("wf_dock").new("Waveform") + wf = dock_area.new("Waveform") # Collect references for validation scan_meta = [] # list of dicts with scan_id, scan_number, data diff --git a/tests/end-2-end/test_rpc_register_e2e.py b/tests/end-2-end/test_rpc_register_e2e.py index 2f8d8f371..5eb521982 100644 --- a/tests/end-2-end/test_rpc_register_e2e.py +++ b/tests/end-2-end/test_rpc_register_e2e.py @@ -9,16 +9,16 @@ def test_rpc_reference_objects(connected_client_gui_obj): gui = connected_client_gui_obj - dock = gui.window_list[0].new() - plt = dock.new(name="fig", widget="Waveform") + dock_area = gui.window_list[0] + plt = dock_area.new("Waveform", object_name="fig") plt.plot(x_name="samx", y_name="bpm4i") - im = dock.new("Image") + im = dock_area.new("Image") im.image("eiger") - motor_map = dock.new("MotorMap") + motor_map = dock_area.new("MotorMap") motor_map.map("samx", "samy") - plt_z = dock.new("Waveform") + plt_z = dock_area.new("Waveform") plt_z.plot(x_name="samx", y_name="samy", z_name="bpm4i") assert len(plt_z.curves) == 1 diff --git a/tests/end-2-end/test_rpc_widgets_e2e.py b/tests/end-2-end/test_rpc_widgets_e2e.py index b513068da..185adfd5e 100644 --- a/tests/end-2-end/test_rpc_widgets_e2e.py +++ b/tests/end-2-end/test_rpc_widgets_e2e.py @@ -1,5 +1,3 @@ -from typing import TYPE_CHECKING - import pytest from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference @@ -64,13 +62,11 @@ def check_reference_registered(): def create_widget( qtbot, gui: RPCBase, dock_area: RPCReference, widget_cls_name: str -) -> tuple[RPCReference, RPCReference, RPCReference]: +) -> RPCReference: """Utility method to create a widget and wait for the namespaces to be created.""" - dock = dock_area.new(widget=widget_cls_name) - wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id) - widget = dock.element_list[-1] - wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id) - return dock, widget + widget = dock_area.new(widget_cls_name) + wait_for_namespace_change(qtbot, gui, dock_area, widget.object_name, widget._gui_id) + return widget @pytest.mark.timeout(100) @@ -106,15 +102,12 @@ def test_available_widgets(qtbot, connected_client_gui_obj): ############################# # Create widget the widget and wait for the widget to be registered in the ipython registry - dock, widget = create_widget( - qtbot, gui, dock_area, getattr(gui.available_widgets, object_name) - ) + widget = create_widget(qtbot, gui, dock_area, getattr(gui.available_widgets, object_name)) # Check that the widget is indeed registered on the server and the client assert gui._ipython_registry.get(widget._gui_id, None) is not None assert gui._server_registry.get(widget._gui_id, None) is not None # Check that namespace was updated - assert hasattr(dock_area, dock.object_name) - assert hasattr(dock, widget.object_name) + assert hasattr(dock_area, widget.object_name) # Check that no additional top level widgets were created without a parent_id widgets = [ @@ -129,19 +122,17 @@ def test_available_widgets(qtbot, connected_client_gui_obj): ############################# # Now we remove the widget again - dock_name = dock.object_name - dock_id = dock._gui_id widget_id = widget._gui_id - dock_area.delete(dock.object_name) + widget.remove() # Wait for namespace to change - wait_for_namespace_change(qtbot, gui, dock_area, dock_name, dock_id, exists=False) - # Assert that dock and widget are removed from the ipython registry and the namespace - assert hasattr(dock_area, dock_name) is False + wait_for_namespace_change( + qtbot, gui, dock_area, widget.object_name, widget_id, exists=False + ) + # Assert that widget is removed from the ipython registry and the namespace + assert hasattr(dock_area, widget.object_name) is False # Client registry - assert gui._ipython_registry.get(dock_id, None) is None assert gui._ipython_registry.get(widget_id, None) is None # Server registry - assert gui._server_registry.get(dock_id, None) is None assert gui._server_registry.get(widget_id, None) is None # Check that the number of top level widgets is still the same. As the cleanup is done by the diff --git a/tests/end-2-end/user_interaction/conftest.py b/tests/end-2-end/user_interaction/conftest.py index f34e7f66c..f7de1d080 100644 --- a/tests/end-2-end/user_interaction/conftest.py +++ b/tests/end-2-end/user_interaction/conftest.py @@ -77,6 +77,10 @@ def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib): try: gui.start(wait=True) qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000) + gui.bec.delete_all() # ensure clean state + qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) yield gui finally: + gui.bec.delete_all() # ensure clean state + qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) gui.kill_server() diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 2ae82b988..076b32d95 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -98,20 +98,16 @@ def check_reference_registered(): ) from e -def create_widget( - qtbot, gui: BECGuiClient, widget_cls_name: str -) -> tuple[RPCReference, RPCReference]: +def create_widget(qtbot, gui: BECGuiClient, widget_cls_name: str) -> RPCReference: """Utility method to create a widget and wait for the namespaces to be created.""" if hasattr(gui, "dock_area"): - dock_area: client.BECDockArea = gui.dock_area + dock_area = gui.dock_area else: - dock_area: client.BECDockArea = gui.new(name="dock_area") + dock_area = gui.new(name="dock_area") wait_for_namespace_change(qtbot, gui, gui, dock_area.object_name, dock_area._gui_id) - dock: client.BECDock = dock_area.new() - wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id) - widget = dock.new(widget=widget_cls_name) - wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id) - return dock, widget + widget = dock_area.new(widget=widget_cls_name) + wait_for_namespace_change(qtbot, gui, dock_area, widget.object_name, widget._gui_id) + return widget @pytest.fixture(scope="module") @@ -133,6 +129,7 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand # Needed, reference gets deleted in the gui name = gui.dock_area.object_name gui_id = gui.dock_area._gui_id + gui.dock_area.delete_all() # start fresh gui.delete("dock_area") wait_for_namespace_change( qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False @@ -144,9 +141,8 @@ def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_ge """Test the BECProgressBar widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar) widget: client.BECProgressBar # Check rpc calls @@ -166,9 +162,8 @@ def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator """Test the BECQueue widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue) widget: client.BECQueue # No rpc calls to test so far @@ -183,8 +178,8 @@ def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_gene """Test the BECStatusBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox) + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox) # Check rpc calls assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"] @@ -198,9 +193,8 @@ def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_gener """Test the DAPComboBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox) widget: client.DAPComboBox # Check rpc calls @@ -217,9 +211,8 @@ def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_gene """Test the DeviceBrowser widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser) widget: client.DeviceBrowser # No rpc calls yet to check @@ -233,9 +226,8 @@ def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_ge """Test the DeviceComboBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox) widget: client.DeviceComboBox assert "samx" in widget.devices @@ -252,9 +244,8 @@ def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_ge """Test the DeviceLineEdit widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit) widget: client.DeviceLineEdit assert widget._is_valid_input is False @@ -273,9 +264,8 @@ def test_widgets_e2e_signal_line_edit(qtbot, connected_client_gui_obj, random_ge """Test the DeviceSignalLineEdit widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit) widget: client.SignalLineEdit widget.set_device("samx") @@ -300,8 +290,8 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen """Test the DeviceSignalComboBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - _, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) widget: client.SignalComboBox widget.set_device("samx") @@ -325,9 +315,8 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro """Test the Image widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Image) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Image) widget: client.Image scans = bec.scans @@ -369,9 +358,8 @@ def _wait_for_scan_in_history(): # """Test the LogPanel widget.""" # gui = connected_client_gui_obj # bec = gui._client -# # Create dock_area, dock, widget -# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel) -# dock: client.BECDock +# # Create dock_area and widget +# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel) # widget: client.LogPanel # # No rpc calls to check so far @@ -385,9 +373,8 @@ def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generat """Test the MineSweeper widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper) widget: client.MineSweeper # No rpc calls to check so far @@ -401,9 +388,8 @@ def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator """Test the MotorMap widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap) widget: client.MotorMap # Test RPC calls @@ -431,9 +417,8 @@ def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_gene """Test MultiWaveform widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform) widget: client.MultiWaveform # Test RPC calls @@ -470,9 +455,8 @@ def test_widgets_e2e_positioner_indicator( """Test the PositionIndicator widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator) widget: client.PositionIndicator # TODO check what these rpc calls are supposed to do! Issue created #461 @@ -487,9 +471,8 @@ def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_gene """Test the PositionerBox widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox) widget: client.PositionerBox # Test rpc calls @@ -510,9 +493,8 @@ def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_g """Test the PositionerBox2D widget.""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D) widget: client.PositionerBox2D # Test rpc calls @@ -537,9 +519,8 @@ def test_widgets_e2e_positioner_control_line( """Test the positioner control line widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine) widget: client.PositionerControlLine # Test rpc calls @@ -555,31 +536,31 @@ def test_widgets_e2e_positioner_control_line( maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the RingProgressBar widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) - dock: client.BECDock - widget: client.RingProgressBar - - widget.set_number_of_bars(3) - widget.rings[0].set_update("manual") - widget.rings[0].set_value(30) - widget.rings[0].set_min_max_values(0, 100) - widget.rings[1].set_update("scan") - widget.rings[2].set_update("device", device="samx") - - # Test rpc calls - dev = bec.device_manager.devices - scans = bec.scans - # Do a scan - scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait() - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) +# TODO passes locally, fails on CI for some reason... -> issue #1003 +# @pytest.mark.timeout(PYTEST_TIMEOUT) +# def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): +# """Test the RingProgressBar widget""" +# gui = connected_client_gui_obj +# bec = gui._client +# # Create dock_area and widget +# widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) +# widget: client.RingProgressBar +# +# widget.set_number_of_bars(3) +# widget.rings[0].set_update("manual") +# widget.rings[0].set_value(30) +# widget.rings[0].set_min_max_values(0, 100) +# widget.rings[1].set_update("scan") +# widget.rings[2].set_update("device", device="samx") +# +# # Test rpc calls +# dev = bec.device_manager.devices +# scans = bec.scans +# # Do a scan +# scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait() +# +# # Test removing the widget, or leaving it open for the next test +# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) @pytest.mark.timeout(PYTEST_TIMEOUT) @@ -587,9 +568,8 @@ def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_genera """Test the ScanControl widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl) widget: client.ScanControl # No rpc calls to check so far @@ -603,9 +583,8 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge """Test the ScatterWaveform widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform) widget: client.ScatterWaveform # Test rpc calls @@ -623,9 +602,8 @@ def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_ """Test the TextBox widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.TextBox) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.TextBox) widget: client.TextBox # RPC calls @@ -641,9 +619,8 @@ def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_ """Test the Waveform widget""" gui = connected_client_gui_obj bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.Waveform) - dock: client.BECDock + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.Waveform) widget: client.Waveform # Test rpc calls diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py deleted file mode 100644 index 2f117ae3f..000000000 --- a/tests/unit_tests/test_bec_dock.py +++ /dev/null @@ -1,233 +0,0 @@ -# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import - -from unittest import mock - -import pytest -from bec_lib.endpoints import MessageEndpoints - -from bec_widgets.widgets.containers.dock import BECDockArea - -from .client_mocks import mocked_client -from .test_bec_queue import bec_queue_msg_full - - -@pytest.fixture -def bec_dock_area(qtbot, mocked_client): - widget = BECDockArea(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_bec_dock_area_init(bec_dock_area): - assert bec_dock_area is not None - assert bec_dock_area.client is not None - assert isinstance(bec_dock_area, BECDockArea) - assert bec_dock_area.config.widget_class == "BECDockArea" - - -def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot): - initial_count = len(bec_dock_area.dock_area.docks) - - # Adding 3 docks - d0 = bec_dock_area.new() - d1 = bec_dock_area.new() - d2 = bec_dock_area.new() - - # Check if the docks were added - assert len(bec_dock_area.dock_area.docks) == initial_count + 3 - assert d0.name() in dict(bec_dock_area.dock_area.docks) - assert d1.name() in dict(bec_dock_area.dock_area.docks) - assert d2.name() in dict(bec_dock_area.dock_area.docks) - assert bec_dock_area.dock_area.docks[d0.name()].config.widget_class == "BECDock" - assert bec_dock_area.dock_area.docks[d1.name()].config.widget_class == "BECDock" - assert bec_dock_area.dock_area.docks[d2.name()].config.widget_class == "BECDock" - - # Check panels API for getting docks to CLI - assert bec_dock_area.panels == dict(bec_dock_area.dock_area.docks) - - # Remove docks - d0_name = d0.name() - bec_dock_area.delete(d0_name) - d1.remove() - - qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == initial_count + 1, timeout=200) - assert d0.name() not in dict(bec_dock_area.dock_area.docks) - assert d1.name() not in dict(bec_dock_area.dock_area.docks) - assert d2.name() in dict(bec_dock_area.dock_area.docks) - - -def test_close_docks(bec_dock_area, qtbot): - _ = bec_dock_area.new(name="dock_0") - _ = bec_dock_area.new(name="dock_1") - _ = bec_dock_area.new(name="dock_2") - - bec_dock_area.delete_all() - qtbot.waitUntil(lambda: len(bec_dock_area.dock_area.docks) == 0) - - -def test_undock_and_dock_docks(bec_dock_area, qtbot): - d0 = bec_dock_area.new(name="dock_0") - d1 = bec_dock_area.new(name="dock_1") - d2 = bec_dock_area.new(name="dock_4") - d3 = bec_dock_area.new(name="dock_3") - - d0.detach() - bec_dock_area.detach_dock("dock_1") - d2.detach() - - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 3 - - d0.attach() - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 2 - - bec_dock_area.attach_all() - assert len(bec_dock_area.dock_area.docks) == 4 - assert len(bec_dock_area.dock_area.tempAreas) == 0 - - -def test_new_dock_raises_for_invalid_name(bec_dock_area): - with pytest.raises(ValueError): - bec_dock_area.new( - name="new", _override_slot_params={"popup_error": False, "raise_error": True} - ) - - -################################### -# Toolbar Actions -################################### -def test_toolbar_add_plot_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["waveform"].action.trigger() - assert "waveform_0" in bec_dock_area.panels - assert bec_dock_area.panels["waveform_0"].widgets[0].config.widget_class == "Waveform" - - -def test_toolbar_add_plot_scatter_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions[ - "scatter_waveform" - ].action.trigger() - assert "scatter_waveform_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["scatter_waveform_0"].widgets[0].config.widget_class - == "ScatterWaveform" - ) - - -def test_toolbar_add_plot_image(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["image"].action.trigger() - assert "image_0" in bec_dock_area.panels - assert bec_dock_area.panels["image_0"].widgets[0].config.widget_class == "Image" - - -def test_toolbar_add_plot_motor_map(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions["motor_map"].action.trigger() - assert "motor_map_0" in bec_dock_area.panels - assert bec_dock_area.panels["motor_map_0"].widgets[0].config.widget_class == "MotorMap" - - -def test_toolbar_add_multi_waveform(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_plots").actions[ - "multi_waveform" - ].action.trigger() - # Check if the MultiWaveform panel is created - assert "multi_waveform_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["multi_waveform_0"].widgets[0].config.widget_class == "MultiWaveform" - ) - - -def test_toolbar_add_device_positioner_box(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_devices").actions[ - "positioner_box" - ].action.trigger() - assert "positioner_box_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["positioner_box_0"].widgets[0].config.widget_class == "PositionerBox" - ) - - -def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full): - bec_dock_area.client.connector.set_and_publish( - MessageEndpoints.scan_queue_status(), bec_queue_msg_full - ) - bec_dock_area.toolbar.components.get_action("menu_utils").actions["queue"].action.trigger() - assert "bec_queue_0" in bec_dock_area.panels - assert bec_dock_area.panels["bec_queue_0"].widgets[0].config.widget_class == "BECQueue" - - -def test_toolbar_add_utils_status(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_utils").actions["status"].action.trigger() - assert "bec_status_box_0" in bec_dock_area.panels - assert bec_dock_area.panels["bec_status_box_0"].widgets[0].config.widget_class == "BECStatusBox" - - -def test_toolbar_add_utils_progress_bar(bec_dock_area): - bec_dock_area.toolbar.components.get_action("menu_utils").actions[ - "progress_bar" - ].action.trigger() - assert "ring_progress_bar_0" in bec_dock_area.panels - assert ( - bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class - == "RingProgressBar" - ) - - -def test_toolbar_screenshot_action(bec_dock_area, tmpdir): - """Test the screenshot functionality from the toolbar.""" - # Create a test screenshot file path in tmpdir - screenshot_path = tmpdir.join("test_screenshot.png") - - # Mock the QFileDialog.getSaveFileName to return a test filename - with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: - mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)") - - # Mock the screenshot.save method - with mock.patch.object(bec_dock_area, "grab") as mock_grab: - mock_screenshot = mock.MagicMock() - mock_grab.return_value = mock_screenshot - - # Trigger the screenshot action - bec_dock_area.toolbar.components.get_action("screenshot").action.trigger() - - # Verify the dialog was called with correct parameters - mock_dialog.assert_called_once() - call_args = mock_dialog.call_args[0] - assert call_args[0] == bec_dock_area # parent widget - assert call_args[1] == "Save Screenshot" # dialog title - assert call_args[2].startswith("bec_") # filename starts with bec_ - assert call_args[2].endswith(".png") # filename ends with .png - assert ( - call_args[3] == "PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)" - ) # file filter - - # Verify grab was called - mock_grab.assert_called_once() - - # Verify save was called with the filename - mock_screenshot.save.assert_called_once_with(str(screenshot_path)) - - -def test_toolbar_screenshot_action_cancelled(bec_dock_area): - """Test the screenshot functionality when user cancels the dialog.""" - # Mock the QFileDialog.getSaveFileName to return empty filename (cancelled) - with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: - mock_dialog.return_value = ("", "") - - # Mock the screenshot.save method - with mock.patch.object(bec_dock_area, "grab") as mock_grab: - mock_screenshot = mock.MagicMock() - mock_grab.return_value = mock_screenshot - - # Trigger the screenshot action - bec_dock_area.toolbar.components.get_action("screenshot").action.trigger() - - # Verify the dialog was called - mock_dialog.assert_called_once() - - # Verify grab was called (screenshot is taken before dialog) - mock_grab.assert_called_once() - - # Verify save was NOT called since dialog was cancelled - mock_screenshot.save.assert_not_called() diff --git a/tests/unit_tests/test_client_plugin_widgets.py b/tests/unit_tests/test_client_plugin_widgets.py index 161fe2d83..d438031fa 100644 --- a/tests/unit_tests/test_client_plugin_widgets.py +++ b/tests/unit_tests/test_client_plugin_widgets.py @@ -1,6 +1,5 @@ import enum import inspect -import sys from importlib import reload from types import SimpleNamespace from unittest.mock import MagicMock, call, patch @@ -59,4 +58,4 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock): ) in bec_logger.logger.warning.mock_calls ) - assert client.BECDock is not _TestDuplicatePlugin + assert client.Waveform is not _TestDuplicatePlugin diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 05b7d61e5..058ccdc7d 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -3,13 +3,13 @@ import pytest -from bec_widgets.cli.client import BECDockArea +from bec_widgets.cli.client import AdvancedDockArea from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process @pytest.fixture def cli_dock_area(): - dock_area = BECDockArea(gui_id="test") + dock_area = AdvancedDockArea(gui_id="test") with mock.patch.object(dock_area, "_run_rpc") as mock_rpc_call: with mock.patch.object(dock_area, "_gui_is_alive", return_value=True): yield dock_area, mock_rpc_call @@ -31,13 +31,13 @@ def test_rpc_call_new_dock(cli_dock_area): ) def test_client_utils_start_plot_process(config, call_config): with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen: - _start_plot_process("gui_id", "bec", config, gui_class="BECDockArea") + _start_plot_process("gui_id", "bec", config, gui_class="AdvancedDockArea") command = [ "bec-gui-server", "--id", "gui_id", "--gui_class", - "BECDockArea", + "AdvancedDockArea", "--gui_class_id", "bec", "--hide", diff --git a/tests/unit_tests/test_plugin_utils.py b/tests/unit_tests/test_plugin_utils.py index 5650b5315..ef9c456e5 100644 --- a/tests/unit_tests/test_plugin_utils.py +++ b/tests/unit_tests/test_plugin_utils.py @@ -8,5 +8,5 @@ def test_client_generator_classes(): assert "Image" in connector_cls_names assert "Waveform" in connector_cls_names - assert "BECDockArea" in plugins + assert "MotorMap" in plugins assert "NonExisting" not in plugins diff --git a/tests/unit_tests/test_rpc_widget_handler.py b/tests/unit_tests/test_rpc_widget_handler.py index 1f2fc7683..558e44e53 100644 --- a/tests/unit_tests/test_rpc_widget_handler.py +++ b/tests/unit_tests/test_rpc_widget_handler.py @@ -1,20 +1,15 @@ -import enum -from importlib import reload -from types import SimpleNamespace -from unittest.mock import MagicMock, call, patch +from unittest.mock import patch -from bec_widgets.cli import client -from bec_widgets.cli.rpc.rpc_base import RPCBase from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo -from bec_widgets.widgets.containers.dock.dock import BECDock def test_rpc_widget_handler(): handler = RPCWidgetHandler() assert "Image" in handler.widget_classes assert "RingProgressBar" in handler.widget_classes + assert "AdvancedDockArea" in handler.widget_classes class _TestPluginWidget(BECWidget): ... From 84950cc651781c48810f84f461ddfcdcc6b7eb53 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Jan 2026 15:20:12 +0100 Subject: [PATCH 068/155] fix(launch_window): argument to start with the gui class --- bec_widgets/applications/bw_launch.py | 5 ++- bec_widgets/applications/launch_window.py | 55 ++++++++++++++++++++++- bec_widgets/cli/server.py | 14 +++--- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 3872532ad..021cfc327 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -20,7 +20,10 @@ def dock_area(object_name: str | None = None, profile: str | None = None) -> Adv AdvancedDockArea: The created advanced dock area. """ widget = AdvancedDockArea( - object_name=object_name, restore_initial_profile=(profile is None), root_widget=True + object_name=object_name, + restore_initial_profile=(profile is None), + root_widget=True, + profile_namespace="bec", ) if profile: widget.load_profile(profile) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 1a458abb9..904e16890 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -182,10 +182,18 @@ def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): class LaunchWindow(BECMainWindow): RPC = True TILE_SIZE = (250, 300) + DEFAULT_WORKSPACE_OPTION = "Last used workspace" USER_ACCESS = ["show_launcher", "hide_launcher"] def __init__( - self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs + self, + parent=None, + gui_id: str = None, + window_title="BEC Launcher", + launch_gui_class: str = None, + launch_gui_id: str = None, + *args, + **kwargs, ): super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) @@ -218,6 +226,7 @@ def __init__( show_selector=True, selector_items=list_profiles("bec"), ) + self._refresh_dock_area_profiles(preserve_selection=False) self.available_auto_updates: dict[str, type[AutoUpdates]] = ( self._update_available_auto_updates() @@ -267,6 +276,11 @@ def __init__( self.register.callbacks.append(self._turn_off_the_lights) self.register.broadcast() + if launch_gui_class and launch_gui_id: + # If a specific gui class is provided, launch it and hide the launcher + self.launch(launch_gui_class, name=launch_gui_id) + self.hide() + def register_tile( self, name: str, @@ -328,6 +342,38 @@ def register_tile( self.tiles[name] = tile + def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None: + """ + Refresh the dock-area profile selector, optionally preserving the selection. + + Args: + preserve_selection(bool): Whether to preserve the current selection or not. + """ + tile = self.tiles.get("dock_area") + if tile is None or tile.selector is None: + return + + selector = tile.selector + selected_text = ( + selector.currentText().strip() if preserve_selection and selector.count() > 0 else "" + ) + + profiles = list_profiles("bec") + selector.blockSignals(True) + selector.clear() + selector.addItem(self.DEFAULT_WORKSPACE_OPTION) + for profile in profiles: + selector.addItem(profile) + + if not selected_text or selected_text == self.DEFAULT_WORKSPACE_OPTION: + idx = 0 + else: + idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly) + if idx < 0: + idx = 0 + selector.setCurrentIndex(idx) + selector.blockSignals(False) + def launch( self, launch_script: str, @@ -501,7 +547,11 @@ def _open_dock_area(self): if tile is None or tile.selector is None: profile = None else: - profile = tile.selector.currentText().strip() or None + selection = tile.selector.currentText().strip() + if not selection or selection == self.DEFAULT_WORKSPACE_OPTION: + profile = None + else: + profile = selection return self.launch("dock_area", profile=profile) def _open_widget(self): @@ -551,6 +601,7 @@ def hide_launcher(self): self.hide() def showEvent(self, event): + self._refresh_dock_area_profiles() super().showEvent(event) self.setFixedSize(self.size()) diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index d27a74f77..c3f8be487 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -107,17 +107,19 @@ def _run(self): self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id) # self.dispatcher.start_cli_server(gui_id=self.gui_id) - self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher") + if self.gui_class: + self.launcher_window = LaunchWindow( + gui_id=f"{self.gui_id}:launcher", + launch_gui_class=self.gui_class, + launch_gui_id=self.gui_class_id, + ) + else: + self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher") self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore self.app.aboutToQuit.connect(self.shutdown) self.app.setQuitOnLastWindowClosed(False) - if self.gui_class: - # If the server is started with a specific gui class, we launch it. - # This will automatically hide the launcher. - self.launcher_window.launch(self.gui_class, name=self.gui_class_id) - def sigint_handler(*args): # display message, for people to let it terminate gracefully print("Caught SIGINT, exiting") From b4e1a7927dcfcda93ffe3d4a301f78fcfb4a46e3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Jan 2026 17:11:58 +0100 Subject: [PATCH 069/155] fix(launch_window): launch geometry for widgets launched from launcher to 80% of the primary screen as default --- bec_widgets/applications/launch_window.py | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 904e16890..f49d3a548 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -433,19 +433,18 @@ def launch( raise ValueError(f"Launch script {launch_script} not found.") result_widget = launch(name, **kwargs) - result_widget.resize(result_widget.minimumSizeHint()) # TODO Should we simply use the specified name as title here? result_widget.window().setWindowTitle(f"BEC - {name}") logger.info(f"Created new dock area: {name}") - if geometry is not None: - result_widget.setGeometry(*geometry) if isinstance(result_widget, BECMainWindow): + self._apply_window_geometry(result_widget, geometry) result_widget.show() else: window = BECMainWindowNoRPC() window.setCentralWidget(result_widget) window.setWindowTitle(f"BEC - {result_widget.objectName()}") + self._apply_window_geometry(window, geometry) window.show() return result_widget @@ -482,6 +481,7 @@ def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow: QApplication.processEvents() window.setWindowTitle(f"BEC - {filename}") + self._apply_window_geometry(window, None) window.show() logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}") return window @@ -498,6 +498,7 @@ def _launch_auto_update(self, auto_update: str) -> AutoUpdates: window.resize(window.minimumSizeHint()) QApplication.processEvents() window.setWindowTitle(f"BEC - {window.objectName()}") + self._apply_window_geometry(window, None) window.show() return window @@ -515,6 +516,7 @@ def _launch_widget(self, widget: type[BECWidget]) -> QWidget: window.setCentralWidget(widget_instance) window.resize(window.minimumSizeHint()) window.setWindowTitle(f"BEC - {widget_instance.objectName()}") + self._apply_window_geometry(window, None) window.show() return window @@ -565,6 +567,31 @@ def _open_widget(self): raise ValueError(f"Widget {widget} not found in available widgets.") return self.launch("widget", widget=self.available_widgets[widget]) + def _apply_window_geometry( + self, window: QWidget, geometry: tuple[int, int, int, int] | None + ) -> None: + """Apply a provided geometry or center the window with an 80% layout.""" + if geometry is not None: + window.setGeometry(*geometry) + return + default_geometry = self._default_window_geometry(window) + if default_geometry is not None: + window.setGeometry(*default_geometry) + else: + window.resize(window.minimumSizeHint()) + + @staticmethod + def _default_window_geometry(window: QWidget) -> tuple[int, int, int, int] | None: + screen = window.screen() or QApplication.primaryScreen() + if screen is None: + return None + available = screen.availableGeometry() + width = int(available.width() * 0.8) + height = int(available.height() * 0.8) + x = available.x() + (available.width() - width) // 2 + y = available.y() + (available.height() - height) // 2 + return x, y, width, height + @SafeSlot(popup_error=True) def _open_custom_ui_file(self): """ From 3cc469a3d108dadf9ed2fe227a0dc4a3631700e0 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Jan 2026 17:50:03 +0100 Subject: [PATCH 070/155] fix(main_app): dock area from main app shares the workspace name with the CLI one to reuse the profiles created in the cli companion window --- bec_widgets/applications/main_app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 1157e32b5..06e9a6000 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -45,9 +45,7 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") - self.ads = AdvancedDockArea( - self, profile_namespace="main_workspace", auto_profile_namespace=False - ) + self.ads = AdvancedDockArea(self, profile_namespace="bec", auto_profile_namespace=False) self.ads.setObjectName("MainWorkspace") self.device_manager = DeviceManagerView(self) self.developer_view = DeveloperView(self) From e94ce73950b97f85ba57ffca0eb45dc60f0fefce Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 13 Jan 2026 11:32:19 +0100 Subject: [PATCH 071/155] fix: sanitize name space util for bec connector and ads --- bec_widgets/utils/bec_connector.py | 3 +++ bec_widgets/utils/name_utils.py | 19 ++++++++++++++++++ .../advanced_dock_area/advanced_dock_area.py | 4 +++- .../advanced_dock_area/basic_dock_area.py | 8 +++++++- .../advanced_dock_area/profile_utils.py | 20 +------------------ 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 6b7ef0c54..91d53e333 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -16,6 +16,7 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot +from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui @@ -102,6 +103,8 @@ def __init__( """ # Extract object_name from kwargs to not pass it to Qt class object_name = object_name or kwargs.pop("objectName", None) + if object_name is not None: + object_name = sanitize_namespace(object_name) # Ensure the parent is always the first argument for QObject parent = kwargs.pop("parent", None) # This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work diff --git a/bec_widgets/utils/name_utils.py b/bec_widgets/utils/name_utils.py index 02fb7b2d1..45c15f058 100644 --- a/bec_widgets/utils/name_utils.py +++ b/bec_widgets/utils/name_utils.py @@ -14,3 +14,22 @@ def pascal_to_snake(name: str) -> str: s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1) return s2.lower() + + +def sanitize_namespace(namespace: str | None) -> str | None: + """ + Clean user-provided namespace labels for filesystem compatibility. + + Args: + namespace (str | None): Arbitrary namespace identifier supplied by the caller. + + Returns: + str | None: Sanitized namespace containing only safe characters, or ``None`` + when the input is empty. + """ + if not namespace: + return None + ns = namespace.strip() + if not ns: + return None + return re.sub(r"[^0-9A-Za-z._-]+", "_", ns) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index a112d26d4..ccd019859 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -21,6 +21,7 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, @@ -48,7 +49,6 @@ profile_origin_display, read_manifest, restore_user_from_default, - sanitize_namespace, set_last_profile, set_quick_select, user_profile_candidates, @@ -277,6 +277,7 @@ def new( title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, show_settings_action: bool | None = None, promote_central: bool = False, + object_name: str | None = None, **widget_kwargs, ) -> QWidget | CDockWidget | BECWidget: """ @@ -301,6 +302,7 @@ def new( title_buttons=title_buttons, show_settings_action=show_settings_action, promote_central=promote_central, + object_name=object_name, **widget_kwargs, ) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 3331619d7..1c9c5ed3a 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -1227,6 +1227,7 @@ def new( promote_central: bool = False, dock_icon: QIcon | None = None, apply_widget_icon: bool = True, + object_name: str | None = None, **widget_kwargs, ) -> QWidget | CDockWidget | BECWidget: """ @@ -1262,6 +1263,9 @@ def new( the widget's ``ICON_NAME`` attribute is used when available. apply_widget_icon(bool): When False, skip automatically resolving the icon from the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. Returns: The widget instance by default, or the created `CDockWidget` when `return_dock` is True. @@ -1273,7 +1277,9 @@ def new( ) widget = cast( BECWidget, - widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs), + widget_handler.create_widget( + widget_type=widget, parent=self, object_name=object_name, **widget_kwargs + ), ) spec = self._build_creation_spec( diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 87f039686..406e50371 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -24,6 +24,7 @@ from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication +from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.widgets.containers.qt_ads import CDockWidget logger = bec_logger.logger @@ -124,25 +125,6 @@ def _settings_profiles_root() -> str: return root -def sanitize_namespace(namespace: str | None) -> str | None: - """ - Clean user-provided namespace labels for filesystem compatibility. - - Args: - namespace (str | None): Arbitrary namespace identifier supplied by the caller. - - Returns: - str | None: Sanitized namespace containing only safe characters, or ``None`` - when the input is empty. - """ - if not namespace: - return None - ns = namespace.strip() - if not ns: - return None - return re.sub(r"[^0-9A-Za-z._-]+", "_", ns) - - def _profiles_dir(segment: str, namespace: str | None) -> str: """ Build (and ensure) the directory that holds profiles for a namespace segment. From 46fe5498b53b58651851d0cec03ca52ccf38c182 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 12 Jan 2026 14:50:37 +0100 Subject: [PATCH 072/155] fix(advanced_dock_area): profile behaviour adjusted, cleanup of the codebase --- bec_widgets/cli/client.py | 40 ++++ .../advanced_dock_area/advanced_dock_area.py | 196 ++++++++++++------ .../advanced_dock_area/profile_utils.py | 8 +- .../settings/workspace_manager.py | 6 +- .../toolbar_components/workspace_actions.py | 4 +- 5 files changed, 184 insertions(+), 70 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 82cfada82..8367674c8 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -214,6 +214,46 @@ def mode(self) -> "str": None """ + @rpc_call + def save_profile( + self, + name: "str | None" = None, + *, + show_dialog: "bool" = False, + quick_select: "bool | None" = None, + ): + """ + Save the current workspace profile. + + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + Read-only bundled profiles cannot be overwritten. + + Args: + name (str | None): The name of the profile to save. If None and show_dialog is True, + prompts the user. + show_dialog (bool): If True, shows the SaveProfileDialog for user interaction. + If False (default), saves directly without user interaction (useful for CLI usage). + quick_select (bool | None): Whether to include the profile in quick selection. + If None (default), uses the existing value or True for new profiles. + Only used when show_dialog is False; otherwise the dialog provides the value. + """ + + @rpc_call + def load_profile(self, name: "str | None" = None): + """ + Load a workspace profile. + + Before switching, persist the current profile to the user copy. + Prefer loading the user copy; fall back to the default copy. + + Args: + name (str | None): The name of the profile to load. If None, prompts the user. + """ + class AutoUpdates(RPCBase): @property diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index ccd019859..83f91e243 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -201,18 +201,7 @@ def _ensure_initial_profile(self) -> bool: f"No profiles found for namespace '{namespace}'. Bootstrapping '{name}' workspace." ) - default_settings = open_default_settings(name, namespace=namespace) - self._write_snapshot_to_settings(default_settings, save_preview=False) - if not default_settings.value(SETTINGS_KEYS["created_at"], ""): - default_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - default_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) - - user_settings = open_user_settings(name, namespace=namespace) - self._write_snapshot_to_settings(user_settings, save_preview=False) - if not user_settings.value(SETTINGS_KEYS["created_at"], ""): - user_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - user_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) - + self._write_profile_settings(name, namespace, save_preview=False) set_quick_select(name, True, namespace=namespace) set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) return True @@ -608,8 +597,63 @@ def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> No logger.info(f"Workspace snapshot written to settings: {settings.fileName()}") + def _write_profile_settings( + self, + name: str, + namespace: str | None, + *, + write_default: bool = True, + write_user: bool = True, + save_preview: bool = True, + ) -> None: + """ + Write profile settings to default and/or user settings files. + + Args: + name: The profile name. + namespace: The profile namespace. + write_default: Whether to write to the default settings file. + write_user: Whether to write to the user settings file. + save_preview: Whether to save a screenshot preview. + """ + if write_default: + ds = open_default_settings(name, namespace=namespace) + self._write_snapshot_to_settings(ds, save_preview=save_preview) + if not ds.value(SETTINGS_KEYS["created_at"], ""): + ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if not ds.value(SETTINGS_KEYS["is_quick_select"], None): + ds.setValue(SETTINGS_KEYS["is_quick_select"], True) + + if write_user: + us = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(us, save_preview=save_preview) + if not us.value(SETTINGS_KEYS["created_at"], ""): + us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if not us.value(SETTINGS_KEYS["is_quick_select"], None): + us.setValue(SETTINGS_KEYS["is_quick_select"], True) + + def _finalize_profile_change(self, name: str, namespace: str | None) -> None: + """ + Finalize a profile change by updating state and refreshing the UI. + + Args: + name: The profile name. + namespace: The profile namespace. + """ + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=name) + @SafeSlot(str) - def save_profile(self, name: str | None = None): + def save_profile( + self, + name: str | None = None, + *, + show_dialog: bool = False, + quick_select: bool | None = None, + ): """ Save the current workspace profile. @@ -621,76 +665,106 @@ def save_profile(self, name: str | None = None): Read-only bundled profiles cannot be overwritten. Args: - name (str | None): The name of the profile to save. If None, prompts the user. + name (str | None): The name of the profile to save. If None and show_dialog is True, + prompts the user. + show_dialog (bool): If True, shows the SaveProfileDialog for user interaction. + If False (default), saves directly without user interaction (useful for CLI usage). + quick_select (bool | None): Whether to include the profile in quick selection. + If None (default), uses the existing value or True for new profiles. + Only used when show_dialog is False; otherwise the dialog provides the value. """ namespace = self.profile_namespace + current_profile = getattr(self, "_current_profile_name", "") or "" def _profile_exists(profile_name: str) -> bool: return profile_origin(profile_name, namespace=namespace) != "unknown" - initial_name = name or "" - quickselect_default = is_quick_select(name, namespace=namespace) if name else True + # Determine final values either from dialog or directly + if show_dialog: + initial_name = name or "" + quickselect_default = is_quick_select(name, namespace=namespace) if name else True + + dialog = SaveProfileDialog( + self, + current_name=initial_name, + current_profile_name=current_profile, + name_exists=_profile_exists, + profile_origin=lambda n: profile_origin(n, namespace=namespace), + origin_label=lambda n: profile_origin_display(n, namespace=namespace), + quick_select_checked=quickselect_default, + ) + if dialog.exec() != QDialog.DialogCode.Accepted: + return - current_profile = getattr(self, "_current_profile_name", "") or "" - dialog = SaveProfileDialog( - self, - current_name=initial_name, - current_profile_name=current_profile, - name_exists=_profile_exists, - profile_origin=lambda n: profile_origin(n, namespace=namespace), - origin_label=lambda n: profile_origin_display(n, namespace=namespace), - quick_select_checked=quickselect_default, - ) - if dialog.exec() != QDialog.DialogCode.Accepted: - return + name = dialog.get_profile_name() + quickselect = dialog.is_quick_select() + overwrite_existing = dialog.overwrite_existing + else: + # CLI / programmatic usage - no dialog + if not name: + logger.warning("save_profile called without name and show_dialog=False") + return + + # Determine quick_select value + if quick_select is None: + # Use existing value if profile exists, otherwise default to True + quickselect = ( + is_quick_select(name, namespace=namespace) if _profile_exists(name) else True + ) + else: + quickselect = quick_select + + # For programmatic saves, check if profile is read-only + origin = profile_origin(name, namespace=namespace) + if origin in {"module", "plugin"}: + logger.warning(f"Cannot save to read-only profile '{name}' (origin: {origin})") + return + + # Overwrite existing settings profile when saving programmatically + overwrite_existing = origin == "settings" - name = dialog.get_profile_name() - quickselect = dialog.is_quick_select() origin_before_save = profile_origin(name, namespace=namespace) - overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" - # Display saving placeholder + overwrite_default = overwrite_existing and origin_before_save == "settings" + + # Display saving placeholder in toolbar workspace_combo = self.toolbar.components.get_action("workspace_combo").widget workspace_combo.blockSignals(True) workspace_combo.insertItem(0, f"{name}-saving") workspace_combo.setCurrentIndex(0) workspace_combo.blockSignals(False) - # Create or update default copy controlled by overwrite flag + # Write to default and/or user settings should_write_default = overwrite_default or not any( os.path.exists(path) for path in default_profile_candidates(name, namespace) ) - if should_write_default: - ds = open_default_settings(name, namespace=namespace) - self._write_snapshot_to_settings(ds) - if not ds.value(SETTINGS_KEYS["created_at"], ""): - ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are quick-select by default - if not ds.value(SETTINGS_KEYS["is_quick_select"], None): - ds.setValue(SETTINGS_KEYS["is_quick_select"], True) - - # Always (over)write the user copy - us = open_user_settings(name, namespace=namespace) - self._write_snapshot_to_settings(us) - if not us.value(SETTINGS_KEYS["created_at"], ""): - us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are quick-select by default (only if missing) - if not us.value(SETTINGS_KEYS["is_quick_select"], None): - us.setValue(SETTINGS_KEYS["is_quick_select"], True) + self._write_profile_settings( + name, namespace, write_default=should_write_default, write_user=True + ) set_quick_select(name, quickselect, namespace=namespace) self._refresh_workspace_list() - if current_profile and current_profile != name and not dialog.overwrite_existing: + if current_profile and current_profile != name and not overwrite_existing: self._pending_autosave_skip = (current_profile, name) else: self._pending_autosave_skip = None workspace_combo.setCurrentText(name) - self._current_profile_name = name - self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) - combo = self.toolbar.components.get_action("workspace_combo").widget - combo.refresh_profiles(active_profile=name) + self._finalize_profile_change(name, namespace) + + @SafeSlot() + def save_profile_dialog(self, name: str | None = None): + """ + Save the current workspace profile with a dialog prompt. + + This is a convenience method for UI usage (toolbar, dialogs) that + always shows the SaveProfileDialog. For programmatic/CLI usage, + use save_profile() directly. + + Args: + name (str | None): Optional initial name to populate in the dialog. + """ + self.save_profile(name, show_dialog=True) def load_profile(self, name: str | None = None): """ @@ -725,7 +799,9 @@ def load_profile(self, name: str | None = None): elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): settings = open_default_settings(name, namespace=namespace) if settings is None: - QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") + logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.") + self.delete_all() + self.save_profile(name, show_dialog=False, quick_select=True) return # Clear existing docks and remove all widgets @@ -759,11 +835,7 @@ def load_profile(self, name: str | None = None): self.state_manager.load_state(settings=settings) self._set_editable(self._editable) - self._current_profile_name = name - self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) - combo = self.toolbar.components.get_action("workspace_combo").widget - combo.refresh_profiles(active_profile=name) + self._finalize_profile_change(name, namespace) @SafeSlot() @SafeSlot(str) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 406e50371..ded47f322 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -20,7 +20,7 @@ from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field -from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt +from qtpy.QtCore import QByteArray, QDateTime, QSettings, QTimeZone from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication @@ -627,7 +627,7 @@ def now_iso_utc() -> str: Returns: str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``). """ - return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) + return QDateTime.currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ssZ") def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: @@ -843,7 +843,9 @@ def _file_modified_iso(path: str) -> str: """ try: mtime = os.path.getmtime(path) - return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) + return QDateTime.fromSecsSinceEpoch(int(mtime), QTimeZone.utc()).toString( + "yyyy-MM-ddTHH:mm:ssZ" + ) except Exception: return now_iso_utc() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index 36357a17e..c5949f602 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -330,7 +330,7 @@ def save_current_as_profile(self): ) return - self.target_widget.save_profile() + self.target_widget.save_profile_dialog() # AdvancedDockArea will emit profile_changed which will trigger table refresh, # but ensure the UI stays in sync even if the signal is delayed. self.render_table() @@ -402,7 +402,7 @@ def resizeEvent(self, event): scaled = pm.scaled( self.screenshot_label.width() or 800, self.screenshot_label.height() or 450, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, ) self.screenshot_label.setPixmap(scaled) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index 58bb8cbe9..88940c0e8 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -156,7 +156,7 @@ def connect(self): # Connect the action to the target widget's method save_action = self.components.get_action("save_workspace").action if save_action.isVisible(): - save_action.triggered.connect(self.target_widget.save_profile) + save_action.triggered.connect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.connect( self.target_widget.load_profile @@ -176,7 +176,7 @@ def disconnect(self): # Disconnect the action from the target widget's method save_action = self.components.get_action("save_workspace").action if save_action.isVisible(): - save_action.triggered.disconnect(self.target_widget.save_profile) + save_action.triggered.disconnect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( self.target_widget.load_profile ) From 3486dd4e44991874078ddedd8633718c59c3ca7d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 13 Jan 2026 12:05:55 +0100 Subject: [PATCH 073/155] fix(advanced_dock_area): remove widget from dock area by object name --- bec_widgets/cli/client.py | 66 +++++++++++++++++++ .../advanced_dock_area/advanced_dock_area.py | 1 + .../advanced_dock_area/basic_dock_area.py | 26 ++++++++ tests/unit_tests/test_advanced_dock_area.py | 28 ++++++++ 4 files changed, 121 insertions(+) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 8367674c8..0320c6135 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -113,6 +113,7 @@ def new( title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, show_settings_action: "bool | None" = None, promote_central: "bool" = False, + object_name: "str | None" = None, **widget_kwargs, ) -> "QWidget | CDockWidget | BECWidget": """ @@ -155,6 +156,25 @@ def delete_all(self): Delete all docks and their associated widgets. """ + @rpc_call + def remove_widget(self, object_name: "str") -> "bool": + """ + Remove a widget from the dock area by its object name. + + Args: + object_name: The object name of the widget to remove. + + Returns: + bool: True if the widget was found and removed, False otherwise. + + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.remove_widget("my_widget") + True + """ + @rpc_call def set_layout_ratios( self, @@ -1058,6 +1078,7 @@ def new( promote_central: "bool" = False, dock_icon: "QIcon | None" = None, apply_widget_icon: "bool" = True, + object_name: "str | None" = None, **widget_kwargs, ) -> "QWidget | CDockWidget | BECWidget": """ @@ -1093,6 +1114,9 @@ def new( the widget's ``ICON_NAME`` attribute is used when available. apply_widget_icon(bool): When False, skip automatically resolving the icon from the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. Returns: The widget instance by default, or the created `CDockWidget` when `return_dock` is True. @@ -1134,6 +1158,25 @@ def delete_all(self): Delete all docks and their associated widgets. """ + @rpc_call + def remove_widget(self, object_name: "str") -> "bool": + """ + Remove a widget from the dock area by its object name. + + Args: + object_name: The object name of the widget to remove. + + Returns: + bool: True if the widget was found and removed, False otherwise. + + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.remove_widget("my_widget") + True + """ + @rpc_call def set_layout_ratios( self, @@ -2723,6 +2766,7 @@ def new( promote_central: "bool" = False, dock_icon: "QIcon | None" = None, apply_widget_icon: "bool" = True, + object_name: "str | None" = None, **widget_kwargs, ) -> "QWidget | CDockWidget | BECWidget": """ @@ -2758,6 +2802,9 @@ def new( the widget's ``ICON_NAME`` attribute is used when available. apply_widget_icon(bool): When False, skip automatically resolving the icon from the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. Returns: The widget instance by default, or the created `CDockWidget` when `return_dock` is True. @@ -2799,6 +2846,25 @@ def delete_all(self): Delete all docks and their associated widgets. """ + @rpc_call + def remove_widget(self, object_name: "str") -> "bool": + """ + Remove a widget from the dock area by its object name. + + Args: + object_name: The object name of the widget to remove. + + Returns: + bool: True if the widget was found and removed, False otherwise. + + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.remove_widget("my_widget") + True + """ + @rpc_call def set_layout_ratios( self, diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 83f91e243..cfc06da00 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -99,6 +99,7 @@ class AdvancedDockArea(DockAreaWidget): "lock_workspace", "attach_all", "delete_all", + "remove_widget", "set_layout_ratios", "describe_layout", "print_layout_structure", diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 1c9c5ed3a..5822f501f 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -54,6 +54,7 @@ class DockAreaWidget(BECWidget, QWidget): "widget_list", "attach_all", "delete_all", + "remove_widget", "set_layout_ratios", "describe_layout", "print_layout_structure", @@ -1375,6 +1376,31 @@ def attach_all(self): QtAds.DockWidgetArea.RightDockWidgetArea, dock, target ) + @SafeSlot(str) + def remove_widget(self, object_name: str) -> bool: + """ + Remove a widget from the dock area by its object name. + + Args: + object_name: The object name of the widget to remove. + + Returns: + bool: True if the widget was found and removed, False otherwise. + + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.remove_widget("my_widget") + True + """ + dock_map = self.dock_map() + dock = dock_map.get(object_name) + if dock is None: + raise ValueError(f"No widget found with object name '{object_name}'.") + self._delete_dock(dock) + return True + @SafeSlot() def delete_all(self): """Delete all docks and their associated widgets.""" diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index f108d39b5..d18a5a8a9 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -250,6 +250,34 @@ def test_attach_all_and_delete_all(self, basic_dock_area): basic_dock_area.delete_all() assert basic_dock_area.dock_list() == [] + def test_remove_widget_by_object_name(self, basic_dock_area, qtbot): + """Test remove_widget removes widget by object name.""" + panel_one = QWidget(parent=basic_dock_area) + panel_one.setObjectName("panel_one") + panel_two = QWidget(parent=basic_dock_area) + panel_two.setObjectName("panel_two") + + basic_dock_area.new(panel_one, return_dock=True) + basic_dock_area.new(panel_two, return_dock=True) + + assert len(basic_dock_area.dock_list()) == 2 + assert "panel_one" in basic_dock_area.dock_map() + assert "panel_two" in basic_dock_area.dock_map() + + # Remove panel_one + result = basic_dock_area.remove_widget("panel_one") + qtbot.wait(100) + + assert result is True + assert len(basic_dock_area.dock_list()) == 1 + assert "panel_one" not in basic_dock_area.dock_map() + assert "panel_two" in basic_dock_area.dock_map() + + def test_remove_widget_raises_for_unknown_name(self, basic_dock_area): + """Test remove_widget raises ValueError for non-existent widget.""" + with pytest.raises(ValueError, match="No widget found with object name 'nonexistent'"): + basic_dock_area.remove_widget("nonexistent") + def test_manifest_serialization_includes_floating_geometry( self, basic_dock_area, qtbot, tmp_path ): From f3470b409d4bed9add7c828fd4e1cdf5efee7dec Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 13 Jan 2026 12:22:12 +0100 Subject: [PATCH 074/155] fix(CLI): dock_area can be created from CLI with specific profile or empty --- bec_widgets/applications/bw_launch.py | 2 +- bec_widgets/applications/launch_window.py | 8 +- bec_widgets/cli/client.py | 18 ++++ bec_widgets/cli/client_utils.py | 38 +++++++- .../advanced_dock_area/advanced_dock_area.py | 93 ++++++++++++++----- .../settings/workspace_manager.py | 69 +++++--------- tests/unit_tests/test_advanced_dock_area.py | 59 ++++++++++-- 7 files changed, 199 insertions(+), 88 deletions(-) diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 021cfc327..b887ee1a1 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -39,7 +39,7 @@ def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates: object_name(str): The name of the dock area. Returns: - BECDockArea: The created dock area. + AdvancedDockArea: The created dock area. """ _auto_update = AutoUpdates(object_name=object_name) return _auto_update diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index f49d3a548..71c2c35ee 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -397,12 +397,10 @@ def launch( with RPCRegister.delayed_broadcast() as rpc_register: existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea) if name is not None: - if name in existing_dock_areas: - raise ValueError( - f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}." - ) WidgetContainerUtils.raise_for_invalid_name(name) - + # If name already exists, generate a unique one with counter suffix + if name in existing_dock_areas: + name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) else: name = "dock_area" name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 0320c6135..e130919c9 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -274,6 +274,24 @@ def load_profile(self, name: "str | None" = None): name (str | None): The name of the profile to load. If None, prompts the user. """ + @rpc_call + def delete_profile(self, name: "str | None" = None, show_dialog: "bool" = False) -> "bool": + """ + Delete a workspace profile. + + Args: + name: The name of the profile to delete. If None, uses the currently + selected profile from the toolbar combo box (for UI usage). + show_dialog: If True, show confirmation dialog before deletion. + Defaults to False for CLI/programmatic usage. + + Returns: + bool: True if the profile was deleted, False otherwise. + + Raises: + ValueError: If the profile is read-only or doesn't exist (when show_dialog=False). + """ + class AutoUpdates(RPCBase): @property diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 622becc6c..6602271bd 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -303,27 +303,55 @@ def new( wait: bool = True, geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", + profile: str | None = None, + empty: bool = False, **kwargs, - ) -> client.BECDockArea: + ) -> client.AdvancedDockArea: """Create a new top-level dock area. Args: name(str, optional): The name of the dock area. Defaults to None. wait(bool, optional): Whether to wait for the server to start. Defaults to True. - geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h) + geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h). + launch_script(str): The launch script to use. Defaults to "dock_area". + profile(str | None): The profile name to load. If None, restores the last used profile. + Use a profile name to load a specific saved profile. + empty(bool): If True, start with an empty dock area without loading any profile. + This takes precedence over the profile argument. Defaults to False. + **kwargs: Additional keyword arguments passed to the dock area. + Returns: - client.BECDockArea: The new dock area. + client.AdvancedDockArea: The new dock area. + + Examples: + >>> gui.new() # Restore last used profile + >>> gui.new(profile="my_profile") # Load specific profile, if profile do not exist, the new profile is created empty with specified name + >>> gui.new(empty=True) # Start with empty dock area + >>> gui.new(name="custom_dock", empty=True) # Named empty dock area """ + if empty: + # Use a unique non-existent profile name to ensure empty start + profile = "__empty__" if not self._check_if_server_is_alive(): self.start(wait=True) if wait: with wait_for_server(self): widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + **kwargs, ) # pylint: disable=protected-access return widget widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + **kwargs, ) # pylint: disable=protected-access return widget diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index cfc06da00..7c0ef7c5f 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -107,6 +107,7 @@ class AdvancedDockArea(DockAreaWidget): "mode.setter", "save_profile", "load_profile", + "delete_profile", ] # Define a signal for mode changes @@ -183,6 +184,7 @@ def __init__( def _ensure_initial_profile(self) -> bool: """ Ensure at least one workspace profile exists for the current namespace. + If list_profile fails due to file permission or corrupted profiles, no action taken. Returns: bool: True if a profile was created, False otherwise. @@ -870,37 +872,82 @@ def restore_user_profile_from_default(self, name: str | None = None): self.load_profile(target) @SafeSlot() - def delete_profile(self): + def delete_profile(self, name: str | None = None, show_dialog: bool = False) -> bool: """ - Delete the currently selected workspace profile file and refresh the combo list. + Delete a workspace profile. + + Args: + name: The name of the profile to delete. If None, uses the currently + selected profile from the toolbar combo box (for UI usage). + show_dialog: If True, show confirmation dialog before deletion. + Defaults to False for CLI/programmatic usage. + + Returns: + bool: True if the profile was deleted, False otherwise. + + Raises: + ValueError: If the profile is read-only or doesn't exist (when show_dialog=False). + """ - combo = self.toolbar.components.get_action("workspace_combo").widget - name = combo.currentText() + # Resolve profile name + if name is None: + combo = self.toolbar.components.get_action("workspace_combo").widget + name = combo.currentText() if not name: - return + if show_dialog: + return False + raise ValueError("No profile name provided.") + + namespace = self.profile_namespace + + # Check if profile is read-only + if is_profile_read_only(name, namespace=namespace): + if show_dialog: + QMessageBox.information( + self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." + ) + return False + raise ValueError(f"Profile '{name}' is read-only and cannot be deleted.") - # Protect bundled/module/plugin profiles from deletion - if is_profile_read_only(name, namespace=self.profile_namespace): - QMessageBox.information( - self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." + # Confirm deletion if dialog is enabled + if show_dialog: + reply = QMessageBox.question( + self, + "Delete Profile", + f"Are you sure you want to delete the profile '{name}'?\n\n" + f"This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - return + if reply != QMessageBox.StandardButton.Yes: + return False - # Confirm deletion for regular profiles - reply = QMessageBox.question( - self, - "Delete Profile", - f"Are you sure you want to delete the profile '{name}'?\n\n" - f"This action cannot be undone.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return + # Perform deletion + try: + removed = delete_profile_files(name, namespace=namespace) + except OSError as exc: + if show_dialog: + QMessageBox.warning( + self, "Delete Profile", f"Failed to delete profile '{name}': {exc}" + ) + return False + raise ValueError(f"Failed to delete profile '{name}': {exc}") from exc - namespace = self.profile_namespace - delete_profile_files(name, namespace=namespace) + if not removed: + if show_dialog: + QMessageBox.information( + self, "Delete Profile", "No writable profile files were found to delete." + ) + return False + raise ValueError(f"No writable profile files found for '{name}'.") + + # Clear current profile if it was deleted + if getattr(self, "_current_profile_name", None) == name: + self._current_profile_name = None + + # Refresh the workspace list self._refresh_workspace_list() + return True def _refresh_workspace_list(self): """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index c5949f602..be9e55426 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -29,7 +29,6 @@ from bec_widgets import BECWidget, SafeSlot from bec_widgets.utils.colors import get_accent_colors from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - delete_profile_files, get_profile_info, is_quick_select, list_profiles, @@ -341,55 +340,35 @@ def save_current_as_profile(self): @SafeSlot(str) def delete_profile(self, profile_name: str): - info = get_profile_info(profile_name, namespace=self.profile_namespace) - if info.is_read_only: - QMessageBox.information( - self, "Delete Profile", "This profile is read-only and cannot be deleted." - ) - return + """ + Delete a profile by delegating to the target widget's delete_profile method. - reply = QMessageBox.question( - self, - "Delete Profile", - ( - f"Delete the profile '{profile_name}'?\n\n" - "This will remove both the user and default copies." - ), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - - try: - removed = delete_profile_files(profile_name, namespace=self.profile_namespace) - except OSError as exc: + Args: + profile_name: The name of the profile to delete. + """ + if self.target_widget is None or not hasattr(self.target_widget, "delete_profile"): QMessageBox.warning( - self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" + self, "Delete Profile", "No target widget available for profile deletion." ) return - if not removed: - QMessageBox.information( - self, "Delete Profile", "No writable profile files were found to delete." - ) - return - - if self.target_widget is not None: - if getattr(self.target_widget, "_current_profile_name", None) == profile_name: - self.target_widget._current_profile_name = None - if hasattr(self.target_widget, "_refresh_workspace_list"): - self.target_widget._refresh_workspace_list() - - self.render_table() - remaining_profiles = list_profiles(namespace=self.profile_namespace) - if remaining_profiles: - next_profile = remaining_profiles[0] - self._select_by_name(next_profile) - self._show_profile_details(next_profile) - else: - self.profile_details_tree.clear() - self.screenshot_label.setPixmap(QPixmap()) + try: + result = self.target_widget.delete_profile(profile_name, show_dialog=True) + except ValueError: + # Error was already handled by target widget's dialog + result = False + + if result: + # Refresh our table and select next profile + self.render_table() + remaining_profiles = list_profiles(namespace=self.profile_namespace) + if remaining_profiles: + next_profile = remaining_profiles[0] + self._select_by_name(next_profile) + self._show_profile_details(next_profile) + else: + self.profile_details_tree.clear() + self.screenshot_label.setPixmap(QPixmap()) def resizeEvent(self, event): super().resizeEvent(event) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d18a5a8a9..26f3a8e21 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -141,9 +141,37 @@ def load_profile(self, name): def save_profile(self): self.save_called = True + def save_profile_dialog(self, name: str | None = None): + """Mock save_profile_dialog that sets save_called flag.""" + self.save_called = True + def _refresh_workspace_list(self): self.refresh_calls += 1 + def delete_profile(self, name: str, show_dialog: bool = False) -> bool: + """Mock delete_profile that performs actual file deletion.""" + from qtpy.QtWidgets import QMessageBox + + from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + delete_profile_files, + is_profile_read_only, + ) + + if is_profile_read_only(name): + if show_dialog: + QMessageBox.information( + None, + "Delete Profile", + f"Profile '{name}' is read-only and cannot be deleted.", + ) + return False + raise ValueError(f"Profile '{name}' is read-only.") + delete_profile_files(name) + if self._current_profile_name == name: + self._current_profile_name = None + self._refresh_workspace_list() + return True + def _factory(): return _Target() @@ -273,11 +301,6 @@ def test_remove_widget_by_object_name(self, basic_dock_area, qtbot): assert "panel_one" not in basic_dock_area.dock_map() assert "panel_two" in basic_dock_area.dock_map() - def test_remove_widget_raises_for_unknown_name(self, basic_dock_area): - """Test remove_widget raises ValueError for non-existent widget.""" - with pytest.raises(ValueError, match="No widget found with object name 'nonexistent'"): - basic_dock_area.remove_widget("nonexistent") - def test_manifest_serialization_includes_floating_geometry( self, basic_dock_area, qtbot, tmp_path ): @@ -1703,7 +1726,7 @@ def is_quick_select(self): "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", StubDialog, ): - advanced_dock_area.save_profile(profile_name) + advanced_dock_area.save_profile(profile_name, show_dialog=True) assert os.path.exists(target_path) @@ -1775,7 +1798,7 @@ def is_quick_select(self): "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", StubDialog, ): - advanced_dock_area.save_profile() + advanced_dock_area.save_profile(show_dialog=True) qtbot.wait(500) source_manifest = read_manifest(helper.open_user(source_profile)) @@ -1844,7 +1867,7 @@ def test_delete_profile_readonly( return_value=None, ) as mock_info, ): - advanced_dock_area.delete_profile() + advanced_dock_area.delete_profile(show_dialog=True) mock_question.assert_not_called() mock_info.assert_called_once() @@ -1875,13 +1898,31 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): mock_question.return_value = QMessageBox.Yes with patch.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh: - advanced_dock_area.delete_profile() + advanced_dock_area.delete_profile(show_dialog=True) mock_question.assert_called_once() mock_refresh.assert_called_once() # Profile should be deleted assert not os.path.exists(user_path) + def test_delete_profile_cli_usage(self, advanced_dock_area, temp_profile_dir): + """Test delete_profile with explicit name (CLI usage - no dialog by default).""" + profile_name = "cli_deletable_profile" + helper = profile_helper(advanced_dock_area) + + # Create regular profile + settings = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + user_path = helper.user_path(profile_name) + assert os.path.exists(user_path) + + # Delete without dialog (CLI usage - default behavior) + result = advanced_dock_area.delete_profile(profile_name) + + assert result is True + assert not os.path.exists(user_path) + def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles From bf7299c31e07c1e3608b18c73b425e8a231bfe6c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 13 Jan 2026 13:22:07 +0100 Subject: [PATCH 075/155] fix(client_utils): delete is deleting window and its content --- bec_widgets/cli/client_utils.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 6602271bd..873031561 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -356,7 +356,7 @@ def new( return widget def delete(self, name: str) -> None: - """Delete a dock area. + """Delete a dock area and its parent window. Args: name(str): The name of the dock area. @@ -364,7 +364,19 @@ def delete(self, name: str) -> None: widget = self.windows.get(name) if widget is None: raise ValueError(f"Dock area {name} not found.") - widget._run_rpc("close") # pylint: disable=protected-access + + # Get the container_proxy (parent window) gui_id from the server registry + obj = self._server_registry.get(widget._gui_id) + if obj is None: + raise ValueError(f"Widget {name} not found in registry.") + + container_gui_id = obj.get("container_proxy") + if container_gui_id: + # Close the container window which will also clean up the dock area + widget._run_rpc("close", gui_id=container_gui_id) # pylint: disable=protected-access + else: + # Fallback: just close the dock area directly + widget._run_rpc("close") # pylint: disable=protected-access def delete_all(self) -> None: """Delete all dock areas.""" From 7c32d47f5205e68f3c0386d5e1bb3a4015f531f9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 14 Jan 2026 16:02:41 +0100 Subject: [PATCH 076/155] fix(advanced_dock_area): replace sanitize_namespace with slugify --- .../advanced_dock_area/advanced_dock_area.py | 10 ++++++---- .../containers/advanced_dock_area/profile_utils.py | 14 +++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 7c0ef7c5f..cb5279b47 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -1,7 +1,9 @@ from __future__ import annotations import os -from typing import Callable, Literal, Mapping, Sequence +from typing import Literal, Mapping, Sequence + +import slugify from bec_lib import bec_logger from qtpy.QtCore import QTimer, Signal @@ -21,7 +23,6 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme -from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, @@ -81,6 +82,7 @@ from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton +from tests.unit_tests.test_modular_toolbar import separator_action logger = bec_logger.logger @@ -130,7 +132,7 @@ def __init__( self._profile_namespace_hint = profile_namespace self._profile_namespace_auto = auto_profile_namespace self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET - self._instance_id = sanitize_namespace(instance_id) if instance_id else None + self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._restore_initial_profile = restore_initial_profile @@ -558,7 +560,7 @@ def _resolve_profile_namespace(self) -> str | None: if not candidate: candidate = self.__class__.__name__ - resolved = sanitize_namespace(candidate) if candidate else None + resolved = slugify.slugify(candidate, separator="_") if candidate else None if not resolved: resolved = "general" self._profile_namespace_resolved = resolved # type: ignore[assignment] diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index ded47f322..267a7bdc2 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -10,12 +10,12 @@ from __future__ import annotations import os -import re import shutil from functools import lru_cache from pathlib import Path from typing import Literal +import slugify from bec_lib import bec_logger from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path @@ -137,7 +137,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str: str: Absolute directory path for the requested segment/namespace pair. """ base = os.path.join(_settings_profiles_root(), segment) - ns = sanitize_namespace(namespace) + ns = slugify.slugify(namespace, separator="_") if namespace else None path = os.path.join(base, ns) if ns else base os.makedirs(path, exist_ok=True) return path @@ -154,7 +154,7 @@ def _user_path_candidates(name: str, namespace: str | None) -> list[str]: Returns: list[str]: Ordered list of candidate user profile paths (.ini files). """ - ns = sanitize_namespace(namespace) + ns = slugify.slugify(namespace, separator="_") if namespace else None primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini") if not ns: return [primary] @@ -173,7 +173,7 @@ def _default_path_candidates(name: str, namespace: str | None) -> list[str]: Returns: list[str]: Ordered list of candidate default profile paths (.ini files). """ - ns = sanitize_namespace(namespace) + ns = slugify.slugify(namespace, separator="_") if namespace else None primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") if not ns: return [primary] @@ -452,7 +452,7 @@ def list_profiles(namespace: str | None = None) -> list[str]: Returns: list[str]: Sorted unique profile names. """ - ns = sanitize_namespace(namespace) + ns = slugify.slugify(namespace, separator="_") if namespace else None def _collect_from(directory: str) -> set[str]: if not os.path.isdir(directory): @@ -553,11 +553,11 @@ def _last_profile_key(namespace: str | None, instance: str | None = None) -> str Returns: str: Scoped key string. """ - ns = sanitize_namespace(namespace) + ns = slugify.slugify(namespace, separator="_") if namespace else None key = SETTINGS_KEYS["last_profile"] if ns: key = f"{key}/{ns}" - inst = sanitize_namespace(instance) if instance else "" + inst = slugify.slugify(instance, separator="_") if instance else "" if inst: key = f"{key}@{inst}" return key From 80f2ca40cba5b7a13460abfd8879f82a90bfe1b2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 14 Jan 2026 16:07:54 +0100 Subject: [PATCH 077/155] fix(advanced_dock_area): CLI API adjustments docs + names --- bec_widgets/cli/client.py | 43 ++++++++++++----- .../advanced_dock_area/advanced_dock_area.py | 48 ++++++++++++++----- .../toolbar_components/workspace_actions.py | 4 +- tests/unit_tests/test_advanced_dock_area.py | 28 +++++------ 4 files changed, 82 insertions(+), 41 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index e130919c9..a284a1f5f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -105,21 +105,46 @@ def new( movable: "bool" = True, start_floating: "bool" = False, where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, - on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, tab_with: "CDockWidget | QWidget | str | None" = None, relative_to: "CDockWidget | QWidget | str | None" = None, - return_dock: "bool" = False, show_title_bar: "bool | None" = None, title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, show_settings_action: "bool | None" = None, promote_central: "bool" = False, object_name: "str | None" = None, **widget_kwargs, - ) -> "QWidget | CDockWidget | BECWidget": + ) -> "QWidget | BECWidget": """ - Override the base helper so dock settings are available by default. + Create a new widget (or reuse an instance) and add it as a dock. - The flag remains user-configurable (pass ``False`` to hide the action). + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. + + Returns: + BECWidget: The created or reused widget instance. """ @rpc_call @@ -136,7 +161,7 @@ def widget_list(self) -> "list[QWidget]": @property @rpc_call - def lock_workspace(self) -> "bool": + def workspace_is_locked(self) -> "bool": """ Get or set the lock state of the workspace. @@ -214,12 +239,6 @@ def describe_layout(self) -> "list[dict[str, Any]]": Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. """ - @rpc_call - def print_layout_structure(self) -> "None": - """ - Pretty-print the current splitter paths to stdout. - """ - @property @rpc_call def mode(self) -> "str": diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index cb5279b47..3072c2c37 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -98,13 +98,12 @@ class AdvancedDockArea(DockAreaWidget): "new", "widget_map", "widget_list", - "lock_workspace", + "workspace_is_locked", "attach_all", "delete_all", "remove_widget", "set_layout_ratios", "describe_layout", - "print_layout_structure", "mode", "mode.setter", "save_profile", @@ -263,21 +262,46 @@ def new( movable: bool = True, start_floating: bool = False, where: Literal["left", "right", "top", "bottom"] | None = None, - on_close: Callable[[CDockWidget, QWidget], None] | None = None, tab_with: CDockWidget | QWidget | str | None = None, relative_to: CDockWidget | QWidget | str | None = None, - return_dock: bool = False, show_title_bar: bool | None = None, title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, show_settings_action: bool | None = None, promote_central: bool = False, object_name: str | None = None, **widget_kwargs, - ) -> QWidget | CDockWidget | BECWidget: + ) -> QWidget | BECWidget: """ - Override the base helper so dock settings are available by default. + Create a new widget (or reuse an instance) and add it as a dock. - The flag remains user-configurable (pass ``False`` to hide the action). + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. + + Returns: + BECWidget: The created or reused widget instance. """ if show_settings_action is None: show_settings_action = True @@ -288,10 +312,8 @@ def new( movable=movable, start_floating=start_floating, where=where, - on_close=on_close, tab_with=tab_with, relative_to=relative_to, - return_dock=return_dock, show_title_bar=show_title_bar, title_buttons=title_buttons, show_settings_action=show_settings_action, @@ -496,7 +518,7 @@ def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]): self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) def _set_editable(self, editable: bool) -> None: - self.lock_workspace = not editable + self.workspace_is_locked = not editable self._editable = editable if self._profile_management_enabled: @@ -510,7 +532,7 @@ def _on_developer_mode_toggled(self, checked: bool) -> None: # Workspace Management ################################################################################ @SafeProperty(bool) - def lock_workspace(self) -> bool: + def workspace_is_locked(self) -> bool: """ Get or set the lock state of the workspace. @@ -519,8 +541,8 @@ def lock_workspace(self) -> bool: """ return self._locked - @lock_workspace.setter - def lock_workspace(self, value: bool): + @workspace_is_locked.setter + def workspace_is_locked(self, value: bool): """ Set the lock state of the workspace. Docks remain resizable, but are not movable or closable. diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index 88940c0e8..9f7fd758c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -147,8 +147,8 @@ def __init__(self, components: ToolbarComponents, target_widget=None): self.bundle_name = "workspace" self.components = components self.target_widget = target_widget - if not hasattr(self.target_widget, "lock_workspace"): - raise AttributeError("Target widget must implement 'lock_workspace'.") + if not hasattr(self.target_widget, "workspace_is_locked"): + raise AttributeError("Target widget must implement 'workspace_is_locked'.") self._connected = False def connect(self): diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 26f3a8e21..d7abcefaa 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -606,7 +606,7 @@ def test_user_access_list(self): "new", "widget_map", "widget_list", - "lock_workspace", + "workspace_is_locked", "attach_all", "delete_all", ] @@ -760,32 +760,32 @@ class TestWorkspaceLocking: """Test workspace locking functionality.""" def test_lock_workspace_property_getter(self, advanced_dock_area): - """Test lock_workspace property getter.""" + """Test workspace_is_locked property getter.""" # Initially unlocked - assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area.workspace_is_locked is False # Set locked state directly advanced_dock_area._locked = True - assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area.workspace_is_locked is True def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): - """Test lock_workspace property setter.""" + """Test workspace_is_locked property setter.""" # Create a dock first advanced_dock_area.new("DarkModeButton") qtbot.wait(200) # Initially unlocked - assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area.workspace_is_locked is False # Lock workspace - advanced_dock_area.lock_workspace = True + advanced_dock_area.workspace_is_locked = True assert advanced_dock_area._locked is True - assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area.workspace_is_locked is True # Unlock workspace - advanced_dock_area.lock_workspace = False + advanced_dock_area.workspace_is_locked = False assert advanced_dock_area._locked is False - assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area.workspace_is_locked is False class TestDeveloperMode: @@ -799,22 +799,22 @@ def test_developer_mode_toggle(self, advanced_dock_area): # Toggle developer mode advanced_dock_area._on_developer_mode_toggled(True) assert advanced_dock_area._editable is True - assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area.workspace_is_locked is False advanced_dock_area._on_developer_mode_toggled(False) assert advanced_dock_area._editable is False - assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area.workspace_is_locked is True def test_set_editable(self, advanced_dock_area): """Test _set_editable functionality.""" # Test setting editable to True advanced_dock_area._set_editable(True) - assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area.workspace_is_locked is False assert advanced_dock_area._editable is True # Test setting editable to False advanced_dock_area._set_editable(False) - assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area.workspace_is_locked is True assert advanced_dock_area._editable is False From 028efed5bc383093917fc1ecb9f2e030c99afe6b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 14 Jan 2026 16:29:43 +0100 Subject: [PATCH 078/155] fix(advanced_dock_area): empty profile is always empty --- bec_widgets/cli/client.py | 11 +++++++++ .../advanced_dock_area/advanced_dock_area.py | 23 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index a284a1f5f..6568ad9d6 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -253,6 +253,16 @@ def mode(self) -> "str": None """ + @rpc_call + def list_profiles(self) -> "list[str]": + """ + List available workspace profiles in the current namespace. + + Returns: + list[str]: List of profile names. + """ + + @rpc_timeout(None) @rpc_call def save_profile( self, @@ -281,6 +291,7 @@ def save_profile( Only used when show_dialog is False; otherwise the dialog provides the value. """ + @rpc_timeout(None) @rpc_call def load_profile(self, name: "str | None" = None): """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 3072c2c37..5b8edd4ed 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -4,7 +4,6 @@ from typing import Literal, Mapping, Sequence import slugify - from bec_lib import bec_logger from qtpy.QtCore import QTimer, Signal from qtpy.QtGui import QPixmap @@ -23,6 +22,7 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, @@ -82,7 +82,6 @@ from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton -from tests.unit_tests.test_modular_toolbar import separator_action logger = bec_logger.logger @@ -106,6 +105,7 @@ class AdvancedDockArea(DockAreaWidget): "describe_layout", "mode", "mode.setter", + "list_profiles", "save_profile", "load_profile", "delete_profile", @@ -673,7 +673,19 @@ def _finalize_profile_change(self, name: str, namespace: str | None) -> None: combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) + @SafeSlot() + def list_profiles(self) -> list[str]: + """ + List available workspace profiles in the current namespace. + + Returns: + list[str]: List of profile names. + """ + namespace = self.profile_namespace + return list_profiles(namespace) + @SafeSlot(str) + @rpc_timeout(None) def save_profile( self, name: str | None = None, @@ -780,6 +792,7 @@ def _profile_exists(profile_name: str) -> bool: self._finalize_profile_change(name, namespace) @SafeSlot() + @SafeSlot(str) def save_profile_dialog(self, name: str | None = None): """ Save the current workspace profile with a dialog prompt. @@ -793,6 +806,8 @@ def save_profile_dialog(self, name: str | None = None): """ self.save_profile(name, show_dialog=True) + @SafeSlot(str) + @rpc_timeout(None) def load_profile(self, name: str | None = None): """ Load a workspace profile. @@ -834,6 +849,10 @@ def load_profile(self, name: str | None = None): # Clear existing docks and remove all widgets self.delete_all() + if name == "__empty__": + self._finalize_profile_change(name, namespace) + return + # Rebuild widgets and restore states for item in read_manifest(settings): obj_name = item["object_name"] From 91050e88aea2fdcb479fef2bf7e14a24dcb668ba Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 14 Jan 2026 17:16:01 +0100 Subject: [PATCH 079/155] refactor(advanced_dock_area): change remove_widget to delete --- bec_widgets/cli/client.py | 12 ++++++------ .../advanced_dock_area/advanced_dock_area.py | 2 +- .../containers/advanced_dock_area/basic_dock_area.py | 6 +++--- tests/unit_tests/test_advanced_dock_area.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 6568ad9d6..a224c2a09 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -182,7 +182,7 @@ def delete_all(self): """ @rpc_call - def remove_widget(self, object_name: "str") -> "bool": + def delete(self, object_name: "str") -> "bool": """ Remove a widget from the dock area by its object name. @@ -196,7 +196,7 @@ def remove_widget(self, object_name: "str") -> "bool": ValueError: If no widget with the given object name is found. Example: - >>> dock_area.remove_widget("my_widget") + >>> dock_area.delete("my_widget") True """ @@ -1207,7 +1207,7 @@ def delete_all(self): """ @rpc_call - def remove_widget(self, object_name: "str") -> "bool": + def delete(self, object_name: "str") -> "bool": """ Remove a widget from the dock area by its object name. @@ -1221,7 +1221,7 @@ def remove_widget(self, object_name: "str") -> "bool": ValueError: If no widget with the given object name is found. Example: - >>> dock_area.remove_widget("my_widget") + >>> dock_area.delete("my_widget") True """ @@ -2895,7 +2895,7 @@ def delete_all(self): """ @rpc_call - def remove_widget(self, object_name: "str") -> "bool": + def delete(self, object_name: "str") -> "bool": """ Remove a widget from the dock area by its object name. @@ -2909,7 +2909,7 @@ def remove_widget(self, object_name: "str") -> "bool": ValueError: If no widget with the given object name is found. Example: - >>> dock_area.remove_widget("my_widget") + >>> dock_area.delete("my_widget") True """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 5b8edd4ed..d913df00e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -100,7 +100,7 @@ class AdvancedDockArea(DockAreaWidget): "workspace_is_locked", "attach_all", "delete_all", - "remove_widget", + "delete", "set_layout_ratios", "describe_layout", "mode", diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 5822f501f..a3e71b0cd 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -54,7 +54,7 @@ class DockAreaWidget(BECWidget, QWidget): "widget_list", "attach_all", "delete_all", - "remove_widget", + "delete", "set_layout_ratios", "describe_layout", "print_layout_structure", @@ -1377,7 +1377,7 @@ def attach_all(self): ) @SafeSlot(str) - def remove_widget(self, object_name: str) -> bool: + def delete(self, object_name: str) -> bool: """ Remove a widget from the dock area by its object name. @@ -1391,7 +1391,7 @@ def remove_widget(self, object_name: str) -> bool: ValueError: If no widget with the given object name is found. Example: - >>> dock_area.remove_widget("my_widget") + >>> dock_area.delete("my_widget") True """ dock_map = self.dock_map() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d7abcefaa..0430af7db 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -293,7 +293,7 @@ def test_remove_widget_by_object_name(self, basic_dock_area, qtbot): assert "panel_two" in basic_dock_area.dock_map() # Remove panel_one - result = basic_dock_area.remove_widget("panel_one") + result = basic_dock_area.delete("panel_one") qtbot.wait(100) assert result is True From 4f2a840c21b17e952b7edb66b584ff203dc93e95 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 15 Jan 2026 17:22:49 +0100 Subject: [PATCH 080/155] fix(CLI): change the default behavior of launching the profiles in CLI --- bec_widgets/applications/bw_launch.py | 24 +++++-- bec_widgets/applications/launch_window.py | 58 ++++++++++++----- bec_widgets/cli/client.py | 3 +- bec_widgets/cli/client_utils.py | 24 +++---- .../advanced_dock_area/advanced_dock_area.py | 63 ++++++++++++------- 5 files changed, 116 insertions(+), 56 deletions(-) diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index b887ee1a1..1a753edb1 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -8,26 +8,38 @@ logger = bec_logger.logger -def dock_area(object_name: str | None = None, profile: str | None = None) -> AdvancedDockArea: +def dock_area( + object_name: str | None = None, profile: str | None = None, start_empty: bool = False +) -> AdvancedDockArea: """ Create an advanced dock area using Qt Advanced Docking System. Args: object_name(str): The name of the advanced dock area. - profile(str|None): Optional profile to load; if None the last profile is restored. + profile(str|None): Optional profile to load; if None the "general" profile is used. + start_empty(bool): If True, start with an empty dock area when loading specified profile. Returns: AdvancedDockArea: The created advanced dock area. + + Note: + The "general" profile is mandatory and will always exist. If manually deleted, + it will be automatically recreated. """ + # Default to "general" profile when called from CLI without specifying a profile + effective_profile = profile if profile is not None else "general" + widget = AdvancedDockArea( object_name=object_name, - restore_initial_profile=(profile is None), + restore_initial_profile=True, root_widget=True, profile_namespace="bec", + init_profile=effective_profile, + start_empty=start_empty, + ) + logger.info( + f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}" ) - if profile: - widget.load_profile(profile) - logger.info(f"Created advanced dock area with profile: {profile}") return widget diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 71c2c35ee..8505b1b8c 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -30,7 +30,10 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_profiles +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + get_last_profile, + list_profiles, +) from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton @@ -182,7 +185,6 @@ def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): class LaunchWindow(BECMainWindow): RPC = True TILE_SIZE = (250, 300) - DEFAULT_WORKSPACE_OPTION = "Last used workspace" USER_ACCESS = ["show_launcher", "hide_launcher"] def __init__( @@ -345,6 +347,7 @@ def register_tile( def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None: """ Refresh the dock-area profile selector, optionally preserving the selection. + Sets the combobox to the last used profile or "general" if no selection preserved. Args: preserve_selection(bool): Whether to preserve the current selection or not. @@ -361,19 +364,49 @@ def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None: profiles = list_profiles("bec") selector.blockSignals(True) selector.clear() - selector.addItem(self.DEFAULT_WORKSPACE_OPTION) for profile in profiles: selector.addItem(profile) - if not selected_text or selected_text == self.DEFAULT_WORKSPACE_OPTION: - idx = 0 - else: + if selected_text: + # Try to preserve the current selection idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly) - if idx < 0: - idx = 0 - selector.setCurrentIndex(idx) + if idx >= 0: + selector.setCurrentIndex(idx) + else: + # Selection no longer exists, fall back to last profile or "general" + self._set_selector_to_default_profile(selector, profiles) + else: + # No selection to preserve, use last profile or "general" + self._set_selector_to_default_profile(selector, profiles) selector.blockSignals(False) + def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None: + """ + Set the selector to the last used profile or "general" as fallback. + + Args: + selector(QComboBox): The combobox to set. + profiles(list[str]): List of available profiles. + """ + # Try to get last used profile + last_profile = get_last_profile(namespace="bec") + if last_profile and last_profile in profiles: + idx = selector.findText(last_profile, Qt.MatchFlag.MatchExactly) + if idx >= 0: + selector.setCurrentIndex(idx) + return + + # Fall back to "general" profile + if "general" in profiles: + idx = selector.findText("general", Qt.MatchFlag.MatchExactly) + if idx >= 0: + selector.setCurrentIndex(idx) + return + + # If nothing else, select first item + if selector.count() > 0: + selector.setCurrentIndex(0) + def launch( self, launch_script: str, @@ -541,17 +574,14 @@ def _open_auto_update(self): def _open_dock_area(self): """ - Open Advanced Dock Area using the selected profile (if any). + Open Advanced Dock Area using the selected profile. """ tile = self.tiles.get("dock_area") if tile is None or tile.selector is None: profile = None else: selection = tile.selector.currentText().strip() - if not selection or selection == self.DEFAULT_WORKSPACE_OPTION: - profile = None - else: - profile = selection + profile = selection if selection else None return self.launch("dock_area", profile=profile) def _open_widget(self): diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index a224c2a09..6fd5dd0cb 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -293,7 +293,7 @@ def save_profile( @rpc_timeout(None) @rpc_call - def load_profile(self, name: "str | None" = None): + def load_profile(self, name: "str | None" = None, start_empty: "bool" = False): """ Load a workspace profile. @@ -302,6 +302,7 @@ def load_profile(self, name: "str | None" = None): Args: name (str | None): The name of the profile to load. If None, prompts the user. + start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. """ @rpc_call diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 873031561..77a24e499 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -304,7 +304,7 @@ def new( geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", profile: str | None = None, - empty: bool = False, + start_empty: bool = False, **kwargs, ) -> client.AdvancedDockArea: """Create a new top-level dock area. @@ -314,24 +314,24 @@ def new( wait(bool, optional): Whether to wait for the server to start. Defaults to True. geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h). launch_script(str): The launch script to use. Defaults to "dock_area". - profile(str | None): The profile name to load. If None, restores the last used profile. + profile(str | None): The profile name to load. If None, loads the "general" profile. Use a profile name to load a specific saved profile. - empty(bool): If True, start with an empty dock area without loading any profile. - This takes precedence over the profile argument. Defaults to False. + start_empty(bool): If True, start with an empty dock area when loading specified profile. **kwargs: Additional keyword arguments passed to the dock area. Returns: client.AdvancedDockArea: The new dock area. + Note: + The "general" profile is mandatory and will always exist. If manually deleted, + it will be automatically recreated. + Examples: - >>> gui.new() # Restore last used profile - >>> gui.new(profile="my_profile") # Load specific profile, if profile do not exist, the new profile is created empty with specified name - >>> gui.new(empty=True) # Start with empty dock area - >>> gui.new(name="custom_dock", empty=True) # Named empty dock area + >>> gui.new() # Start with the "general" profile + >>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name + >>> gui.new(start_empty=True) # Start with "general" profile but empty dock area + >>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area """ - if empty: - # Use a unique non-existent profile name to ensure empty start - profile = "__empty__" if not self._check_if_server_is_alive(): self.start(wait=True) if wait: @@ -342,6 +342,7 @@ def new( name=name, geometry=geometry, profile=profile, + start_empty=start_empty, **kwargs, ) # pylint: disable=protected-access return widget @@ -351,6 +352,7 @@ def new( name=name, geometry=geometry, profile=profile, + start_empty=start_empty, **kwargs, ) # pylint: disable=protected-access return widget diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index d913df00e..c9a4812e9 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -126,6 +126,8 @@ def __init__( auto_save_upon_exit: bool = True, enable_profile_management: bool = True, restore_initial_profile: bool = True, + init_profile: str | None = None, + start_empty: bool = False, **kwargs, ): self._profile_namespace_hint = profile_namespace @@ -135,6 +137,8 @@ def __init__( self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._restore_initial_profile = restore_initial_profile + self._init_profile = init_profile + self._start_empty = start_empty super().__init__( parent, default_add_direction=default_add_direction, @@ -184,7 +188,8 @@ def __init__( def _ensure_initial_profile(self) -> bool: """ - Ensure at least one workspace profile exists for the current namespace. + Ensure the "general" workspace profile always exists for the current namespace. + The "general" profile is mandatory and will be recreated if deleted. If list_profile fails due to file permission or corrupted profiles, no action taken. Returns: @@ -197,12 +202,13 @@ def _ensure_initial_profile(self) -> bool: logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}") return False - if existing_profiles: + # Always ensure "general" profile exists + name = "general" + if name in existing_profiles: return False - name = "general" logger.info( - f"No profiles found for namespace '{namespace}'. Bootstrapping '{name}' workspace." + f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace." ) self._write_profile_settings(name, namespace, save_preview=False) @@ -215,30 +221,37 @@ def _fetch_initial_profile(self): combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_namespace init_profile = None - instance_id = self._last_profile_instance_id() - if instance_id: - inst_profile = get_last_profile( - namespace=namespace, instance=instance_id, allow_namespace_fallback=False - ) - if inst_profile and self._profile_exists(inst_profile, namespace): - init_profile = inst_profile - if not init_profile: - last = get_last_profile(namespace=namespace) - if last and self._profile_exists(last, namespace): - init_profile = last - else: - text = combo.currentText() - init_profile = text if text else None - if not init_profile: - if self._profile_exists("general", namespace): - init_profile = "general" + + # First priority: use init_profile if explicitly provided + if self._init_profile: + init_profile = self._init_profile + else: + # Try to restore from last used profile + instance_id = self._last_profile_instance_id() + if instance_id: + inst_profile = get_last_profile( + namespace=namespace, instance=instance_id, allow_namespace_fallback=False + ) + if inst_profile and self._profile_exists(inst_profile, namespace): + init_profile = inst_profile + if not init_profile: + last = get_last_profile(namespace=namespace) + if last and self._profile_exists(last, namespace): + init_profile = last + else: + text = combo.currentText() + init_profile = text if text else None + if not init_profile: + # Fall back to "general" profile which is guaranteed to exist + if self._profile_exists("general", namespace): + init_profile = "general" if init_profile: # Defer initial load to the event loop so child widgets exist before state restore. QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) def _load_initial_profile(self, name: str) -> None: """Load the initial profile after construction when the event loop is running.""" - self.load_profile(name) + self.load_profile(name, start_empty=self._start_empty) combo = self.toolbar.components.get_action("workspace_combo").widget combo.blockSignals(True) combo.setCurrentText(name) @@ -807,8 +820,9 @@ def save_profile_dialog(self, name: str | None = None): self.save_profile(name, show_dialog=True) @SafeSlot(str) + @SafeSlot(str, bool) @rpc_timeout(None) - def load_profile(self, name: str | None = None): + def load_profile(self, name: str | None = None, start_empty: bool = False): """ Load a workspace profile. @@ -817,6 +831,7 @@ def load_profile(self, name: str | None = None): Args: name (str | None): The name of the profile to load. If None, prompts the user. + start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. """ if not name: # Gui fallback if the name is not provided name, ok = QInputDialog.getText( @@ -849,7 +864,7 @@ def load_profile(self, name: str | None = None): # Clear existing docks and remove all widgets self.delete_all() - if name == "__empty__": + if start_empty: self._finalize_profile_change(name, namespace) return From c41ef4401dd9e1508732b3d36ce8569a3254db86 Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 7 Jan 2026 15:04:58 +0100 Subject: [PATCH 081/155] fix(device-form-dialog): Adapt DeviceFormDialog to run validation of config upon editing/adding a config, and forward validation results --- .../device_form_dialog.py | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index ca31df964..7acc7a745 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -47,8 +47,6 @@ def __init__(self, parent=None, config: dict | None = None): # type:ignore self.text_box.setReadOnly(True) layout.addWidget(self.text_box) - # Connect signal for validation messages - # Load and apply configuration config = config or {} self.device_manager_ophyd_test.change_device_configs([config], True, True) @@ -188,6 +186,9 @@ def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignor self.update_variant_combo(self._control_widgets["group_combo"].currentText()) self.finished.connect(self._finished) + # Wait dialog when adding config + self._wait_dialog: QtWidgets.QProgressDialog | None = None + @SafeSlot(int) def _finished(self, state: int): for widget in self._control_widgets.values(): @@ -268,13 +269,24 @@ def update_device_config_template(self, variant_name: str): OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE] ) - def _add_config(self): - config = self._device_config_template.get_config_fields() - config_status = ConfigStatus.UNKNOWN.value - connection_status = ConnectionStatus.UNKNOWN.value - validation_msg = "" + def _create_validation_dialog(self) -> QtWidgets.QProgressDialog: + """ + Create and show a validation progress dialog while validating the device configuration. + The dialog will be modal and prevent user interaction until validation is complete. + """ + wait_dialog = QtWidgets.QProgressDialog("Validating… please wait", None, 0, 0, parent=self) + wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) + wait_dialog.setCancelButton(None) + wait_dialog.setMinimumDuration(0) + return wait_dialog + + @SafeSlot(dict, int, int, str) + def _validation_complete( + self, device_config: dict, config_status: int, connection_status: int, validation_msg: str + ): + """Handle completion of validation.""" try: - if DeviceModel.model_validate(config) == DeviceModel.model_validate( + if DeviceModel.model_validate(device_config) == DeviceModel.model_validate( self._validation_result[0] ): config_status = self._validation_result[1] @@ -282,9 +294,25 @@ def _add_config(self): validation_msg = self._validation_result[3] except Exception: logger.debug( - f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses." + f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation ." ) + self._validation_result = (device_config, config_status, connection_status, validation_msg) + self._wait_dialog.finished.emit(0) + if self._wait_dialog is not None: + self._wait_dialog.close() + self._wait_dialog.deleteLater() + self._wait_dialog = None + def _add_config(self): + """ + + Adding a config will always run a validation check of the config without a connection test. + We will check if tests have already run, and reuse the information in case they also tested the connection to the device. + """ + config = self._device_config_template.get_config_fields() + + # I. First we validate that the device name is valid, as this may create issues within the OphydValidation widget. + # Validate device name first. If invalid, this should immediately block adding the device. if not validate_name(config.get("name", "")): msg_box = self._create_warning_message_box( "Invalid Device Name", @@ -292,18 +320,36 @@ def _add_config(self): ) msg_box.exec() return + + # II. Next we will run the validation check of the config without connection test. + # We will show a wait dialog while this is happening, and compare the results with the last known validation results. + # If the config is unchanged, we will use the connection status results from the last validation. + self._wait_dialog = self._create_validation_dialog() + + ophyd_validation = OphydValidation() + ophyd_validation.validation_completed.connect(self._validation_complete) + ophyd_validation.change_device_configs([config], True, False) + + res = self._wait_dialog.exec() # This will block until the validation is complete + + config, config_status, connection_status, validation_msg = self._validation_result + if config_status == ConfigStatus.INVALID.value: msg_box = self._create_warning_message_box( "Invalid Device Configuration", - f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}", + f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{self._validation_result[3]}", ) msg_box.exec() + ophyd_validation.close() + ophyd_validation.deleteLater() return self.accepted_data.emit( config, config_status, connection_status, validation_msg, self._old_device_name ) self.accept() + ophyd_validation.close() + ophyd_validation.deleteLater() def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox: msg_box = QtWidgets.QMessageBox(self) @@ -318,7 +364,6 @@ def _test_connection(self): result = dialog.exec() if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected): self.config_validation_result = dialog.validation_result - # self._device_config_template.set_config_fields(self.config_validation_result[0]) def _reset_config(self): self._device_config_template.reset_to_defaults() From 0467d88010624a01b1bc73b52a85295a4f73f91f Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 07:52:07 +0100 Subject: [PATCH 082/155] fix(device-form-dialog): Adapt device-form-dialog ophyd validation test --- .../device_form_dialog.py | 55 +++++++++++++++++-- .../device_manager_display_widget.py | 10 ++-- .../components/device_table/device_table.py | 35 +++++++----- .../ophyd_validation/ophyd_validation.py | 27 ++++++++- 4 files changed, 102 insertions(+), 25 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 7acc7a745..c0453f938 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -1,5 +1,7 @@ """Dialogs for device configuration forms and ophyd testing.""" +from typing import Any, Iterable, Tuple + from bec_lib.atlas_models import Device as DeviceModel from bec_lib.logger import bec_logger from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES @@ -20,6 +22,7 @@ ) DEFAULT_DEVICE = "CustomDevice" +_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]] logger = bec_logger.logger @@ -194,6 +197,9 @@ def _finished(self, state: int): for widget in self._control_widgets.values(): widget.close() widget.deleteLater() + if self._wait_dialog is not None: + self._wait_dialog.close() + self._wait_dialog.deleteLater() @property def config_validation_result(self) -> tuple[dict, int, int, str]: @@ -274,14 +280,36 @@ def _create_validation_dialog(self) -> QtWidgets.QProgressDialog: Create and show a validation progress dialog while validating the device configuration. The dialog will be modal and prevent user interaction until validation is complete. """ - wait_dialog = QtWidgets.QProgressDialog("Validating… please wait", None, 0, 0, parent=self) + wait_dialog = QtWidgets.QProgressDialog( + "Validating config… please wait", None, 0, 0, parent=self + ) wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) wait_dialog.setCancelButton(None) wait_dialog.setMinimumDuration(0) return wait_dialog + @SafeSlot(list) + def _handle_devices_already_in_session_results( + self, validation_results: _ValidationResultIter + ) -> None: + """Handle completion if device is already in session.""" + if len(validation_results) != 1: + logger.error( + "Expected a single device validation result, but got multiple. Using first result." + ) + result = validation_results[0] if len(validation_results) > 0 else None + if result is None: + logger.error( + f"Received validation results: {validation_results} of unexpected length 0. Returning." + ) + return + device_config, config_status, connection_status, validation_msg = result + self._handle_validation_result( + device_config, config_status, connection_status, validation_msg + ) + @SafeSlot(dict, int, int, str) - def _validation_complete( + def _handle_validation_result( self, device_config: dict, config_status: int, connection_status: int, validation_msg: str ): """Handle completion of validation.""" @@ -297,8 +325,8 @@ def _validation_complete( f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation ." ) self._validation_result = (device_config, config_status, connection_status, validation_msg) - self._wait_dialog.finished.emit(0) if self._wait_dialog is not None: + self._wait_dialog.accept() self._wait_dialog.close() self._wait_dialog.deleteLater() self._wait_dialog = None @@ -327,10 +355,25 @@ def _add_config(self): self._wait_dialog = self._create_validation_dialog() ophyd_validation = OphydValidation() - ophyd_validation.validation_completed.connect(self._validation_complete) - ophyd_validation.change_device_configs([config], True, False) + ophyd_validation.validation_completed.connect(self._handle_validation_result) + ophyd_validation.multiple_validations_completed.connect( + self._handle_devices_already_in_session_results + ) - res = self._wait_dialog.exec() # This will block until the validation is complete + # NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled + # tasks in the event loop are processed. This avoids potential deadlocks. In particular, + # this is relevant for the _wait_dialog exec which opens a modal dialog during validation + # and therefore must not have the signal emitted immediately in the same event loop iteration. + # Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock. + QtCore.QTimer.singleShot( + 0, lambda: ophyd_validation.change_device_configs([config], True, False) + ) + + # NOTE If dialog was already close, this means that a validation callback was already received + # which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above, + # this should not happen, but we keep the check for safety. + if self._wait_dialog is not None: + self._wait_dialog.exec() # This will block until the validation is complete config, config_status, connection_status, validation_msg = self._validation_result diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index d33abd292..34031264f 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -104,10 +104,10 @@ def __init__(self, parent=None, client=None, *args, **kwargs): self.ophyd_test_view.multiple_validations_completed, (self.device_table_view.update_multiple_device_validations,), ), - (self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)), + (self.request_ophyd_validation, (self.ophyd_test_view.device_table_config_changed,)), ( self.device_table_view.device_configs_changed, - (self.ophyd_test_view.change_device_configs,), + (self.ophyd_test_view.device_table_config_changed,), ), ( self.device_table_view.device_config_in_sync_with_redis, @@ -591,7 +591,8 @@ def _update_device_to_table_from_dialog( ): if old_device_name and old_device_name != data.get("name", ""): self.device_table_view.remove_device(old_device_name) - self.device_table_view.update_device_configs([data]) + self.device_table_view.update_device_configs([data], skip_validation=True) + self.device_table_view.update_device_validation(data, config_status, connection_status, msg) @SafeSlot(dict, int, int, str, str) def _add_to_table_from_dialog( @@ -602,7 +603,8 @@ def _add_to_table_from_dialog( msg: str, old_device_name: str = "", ): - self.device_table_view.add_device_configs([data]) + self.device_table_view.add_device_configs([data], skip_validation=True) + self.device_table_view.update_device_validation(data, config_status, connection_status, msg) @SafeSlot() def _remove_device_action(self): diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index 5e7b665c3..c930b582a 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -199,7 +199,8 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): # Signal emitted if devices are added (updated) or removed # - device_configs: List of device configurations. # - added: True if devices were added/updated, False if removed. - device_configs_changed = QtCore.Signal(list, bool) + # - skip validation: True if validation should be skipped for added/updated devices. + device_configs_changed = QtCore.Signal(list, bool, bool) # Signal emitted when device selection changes, emits list of selected device configs selected_devices = QtCore.Signal(list) # Signal emitted when a device row is double-clicked, emits the device config @@ -823,7 +824,7 @@ def get_selected_device_configs(self) -> list[dict]: # ------------------------------------------------------------------------- @SafeSlot(list) - def set_device_config(self, device_configs: _DeviceCfgIter): + def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ Set the device config. This will clear any existing configs. @@ -837,27 +838,31 @@ def set_device_config(self, device_configs: _DeviceCfgIter): for cfg in device_configs: self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) cfgs_added.append(cfg) - self.device_configs_changed.emit(cfgs_added, True) + self.device_configs_changed.emit(cfgs_added, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") @SafeSlot() def clear_device_configs(self): - """Clear the device configs.""" + """Clear the device configs. Skips validation per default.""" self.set_busy(True, text="Clearing device configurations...") device_configs = self.get_device_config() with self.table_sort_on_hold: self._clear_table() - self.device_configs_changed.emit(device_configs, False) + self.device_configs_changed.emit( + device_configs, False, True + ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") @SafeSlot(list) - def add_device_configs(self, device_configs: _DeviceCfgIter): + def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ - Add devices to the config. If a device already exists, it will be replaced. + Add devices to the config. If a device already exists, it will be replaced. If the validation is + skipped, the device will be added with UNKNOWN state to the table and has to be manually adjusted + by the user later on. Args: device_configs (Iterable[dict[str, Any]]): The device configs to add. @@ -875,20 +880,22 @@ def add_device_configs(self, device_configs: _DeviceCfgIter): # Remove existing rows first if len(already_in_table) > 0: self._remove_rows_by_name([cfg["name"] for cfg in already_in_table]) - self.device_configs_changed.emit(already_in_table, False) + self.device_configs_changed.emit( + already_in_table, False, True + ) # Skip validation for removals all_configs = already_in_table + not_in_table if len(all_configs) > 0: for cfg in already_in_table + not_in_table: self._add_row(cfg, ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN) - self.device_configs_changed.emit(already_in_table + not_in_table, True) + self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") @SafeSlot(list) - def update_device_configs(self, device_configs: _DeviceCfgIter): + def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ Update devices in the config. If a device does not exist, it will be added. @@ -907,7 +914,7 @@ def update_device_configs(self, device_configs: _DeviceCfgIter): row = self._update_row(cfg) if row is not None: cfgs_updated.append(cfg) - self.device_configs_changed.emit(cfgs_updated, True) + self.device_configs_changed.emit(cfgs_updated, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") @@ -924,7 +931,9 @@ def remove_device_configs(self, device_configs: _DeviceCfgIter): cfgs_to_be_removed = list(device_configs) with self.table_sort_on_hold: self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed]) - self.device_configs_changed.emit(cfgs_to_be_removed, False) # + self.device_configs_changed.emit( + cfgs_to_be_removed, False, True + ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") @@ -946,7 +955,7 @@ def remove_device(self, device_name: str): with self.table_sort_on_hold: self._remove_rows_by_name([row_data.data["name"]]) cfgs = [{"name": device_name, **row_data.data}] - self.device_configs_changed.emit(cfgs, False) + self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index 9838720d7..eb2acfd99 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -470,9 +470,19 @@ def get_device_configs(self) -> list[dict[str, Any]]: widgets: list[ValidationListItem] = self.list_widget.get_widgets() return [widget.device_model.device_config for widget in widgets] + @SafeSlot(list, bool, bool) + def device_table_config_changed( + self, device_configs: list[dict[str, Any]], added: bool, skip_validation: bool + ) -> None: + """Slot to handle device config changes in the device table.""" + self.change_device_configs( + device_configs=device_configs, added=added, skip_validation=skip_validation + ) + @SafeSlot(list, bool) @SafeSlot(list, bool, bool) @SafeSlot(list, bool, bool, bool, float) + @SafeSlot(list, bool, bool, bool, float, bool) def change_device_configs( self, device_configs: list[dict[str, Any]], @@ -480,11 +490,17 @@ def change_device_configs( connect: bool = False, force_connect: bool = False, timeout: float = 5.0, + skip_validation: bool = False, ) -> None: """ Change the device configuration to test. If added is False, existing devices are removed. Device tests will be removed based on device names. No duplicates are allowed. + For validation runs, results are emitted via the validation_completed signal. Unless devices + are already in the running session with the same config, in which case the combined results + of all such devices are emitted via the multiple_validations_completed signal. NOTE Please make + sure to connect to both signals if you want to capture all results. + Args: device_configs (list[dict[str, Any]]): List of device configurations. added (bool): Whether the devices are added to the existing list. @@ -504,7 +520,7 @@ def change_device_configs( if device_name is None: # Config missing name, will be skipped.. logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.") continue - if not added: # Remove requested + if not added or skip_validation is True: # Remove requested self._remove_device_config(cfg) continue if self._is_device_in_redis_session(cfg.get("name"), cfg): @@ -533,7 +549,14 @@ def change_device_configs( ) # Send out batch of updates for devices already in session if devices_already_in_session: - self.multiple_validations_completed.emit(devices_already_in_session) + # NOTE: Use singleShot here to ensure that the signal is emitted after all other scheduled + # tasks in the event loop are processed. This avoids potential deadlocks. In particular, + # this is relevant for the DeviceFormDialog which opens a modal dialog during validation + # and therefore must not have the signal emitted immediately in the same event loop iteration. + # Otherwise, the dialog would block signal processing. + QtCore.QTimer.singleShot( + 0, lambda: self.multiple_validations_completed.emit(devices_already_in_session) + ) def cancel_validation(self, device_name: str) -> None: """Cancel a running validation for a specific device. From 73cfe8da4c68a362753e0ebc7d7b75cd02a38b46 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 08:19:05 +0100 Subject: [PATCH 083/155] test(device-form-dialog): adapt tests --- .../device_form_dialog.py | 33 +++--- .../device_manager_display_widget.py | 2 +- .../test_device_manager_components.py | 3 +- tests/unit_tests/test_device_manager_view.py | 105 +++++++++++------- 4 files changed, 86 insertions(+), 57 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index c0453f938..c5fc18b32 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -288,6 +288,24 @@ def _create_validation_dialog(self) -> QtWidgets.QProgressDialog: wait_dialog.setMinimumDuration(0) return wait_dialog + def _create_and_run_ophyd_validation(self, config: dict[str, Any]) -> OphydValidation: + """Run ophyd validation test on the current device configuration.""" + ophyd_validation = OphydValidation(parent=self) + ophyd_validation.validation_completed.connect(self._handle_validation_result) + ophyd_validation.multiple_validations_completed.connect( + self._handle_devices_already_in_session_results + ) + + # NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled + # tasks in the event loop are processed. This avoids potential deadlocks. In particular, + # this is relevant for the _wait_dialog exec which opens a modal dialog during validation + # and therefore must not have the signal emitted immediately in the same event loop iteration. + # Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock. + QtCore.QTimer.singleShot( + 0, lambda: ophyd_validation.change_device_configs([config], True, False) + ) + return ophyd_validation + @SafeSlot(list) def _handle_devices_already_in_session_results( self, validation_results: _ValidationResultIter @@ -354,20 +372,7 @@ def _add_config(self): # If the config is unchanged, we will use the connection status results from the last validation. self._wait_dialog = self._create_validation_dialog() - ophyd_validation = OphydValidation() - ophyd_validation.validation_completed.connect(self._handle_validation_result) - ophyd_validation.multiple_validations_completed.connect( - self._handle_devices_already_in_session_results - ) - - # NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled - # tasks in the event loop are processed. This avoids potential deadlocks. In particular, - # this is relevant for the _wait_dialog exec which opens a modal dialog during validation - # and therefore must not have the signal emitted immediately in the same event loop iteration. - # Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock. - QtCore.QTimer.singleShot( - 0, lambda: ophyd_validation.change_device_configs([config], True, False) - ) + ophyd_validation = self._create_and_run_ophyd_validation(config) # NOTE If dialog was already close, this means that a validation callback was already received # which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above, diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 34031264f..15b7f0c1d 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -104,7 +104,7 @@ def __init__(self, parent=None, client=None, *args, **kwargs): self.ophyd_test_view.multiple_validations_completed, (self.device_table_view.update_multiple_device_validations,), ), - (self.request_ophyd_validation, (self.ophyd_test_view.device_table_config_changed,)), + (self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)), ( self.device_table_view.device_configs_changed, (self.ophyd_test_view.device_table_config_changed,), diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index 9964a396a..a690d059d 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -575,9 +575,10 @@ def test_remove_device_configs(self, device_table: DeviceTable, sample_devices: device_table.remove_device_configs([sample_devices[0]]) # Verify signal emission - emitted_configs, added = blocker.args + emitted_configs, added, skip_validation = blocker.args assert len(emitted_configs) == 1 assert added is False + assert skip_validation is True # Verify row was removed assert device_table.table.rowCount() == 1 diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 220fa8cc0..e0609d934 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -184,48 +184,67 @@ def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): assert config["name"] == "TestDevice" assert config["deviceClass"] == "ophyd.EpicsSignal" assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" - # Set the validation results, assume that test was running - dialog.config_validation_result = ( - dialog._device_config_template.get_config_fields(), - ConfigStatus.VALID.value, - 0, - "", - ) - with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: - with qtbot.waitSignal(dialog.accepted_data) as sig_blocker: - qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) - config, _, _, _, _ = sig_blocker.args - mock_warning_box.assert_not_called() - - # Called with config_status invalid should show warning - dialog.config_validation_result = ( - dialog._device_config_template.get_config_fields(), - ConfigStatus.INVALID.value, - 0, - "", - ) - with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: - qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) - mock_warning_box.assert_called_once() - # Set to random config without name + # Test now to add the device config with different validation results + # For this we have to mock the additional ophyd_validation checks + with ( + mock.patch.object(dialog, "_create_validation_dialog") as mock_create_dialog, + mock.patch.object(dialog, "_create_and_run_ophyd_validation") as mock_create_validation, + ): - random_config = {"deviceClass": "Unknown"} - dialog.set_device_config(random_config) - dialog.config_validation_result = ( - dialog._device_config_template.get_config_fields(), - 0, - 0, - "", - ) - assert group_combo.currentText() == "CustomDevice" - assert variant_combo.currentText() == "CustomDevice" - with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: - qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) - mock_warning_box.assert_called_once_with( - "Invalid Device Name", - f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + # Set the validation results, assume that test was running + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.VALID.value, + 0, + "", + ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + with qtbot.waitSignal(dialog.accepted_data) as sig_blocker: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + config, _, _, _, _ = sig_blocker.args + mock_warning_box.assert_not_called() + + mock_create_dialog.assert_called_once() + mock_create_validation.assert_called_once() + mock_create_dialog.reset_mock() + mock_create_validation.reset_mock() + + # Called with config_status invalid should show warning + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + ConfigStatus.INVALID.value, + 0, + "", ) + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once() + mock_create_dialog.assert_called_once() + mock_create_validation.assert_called_once() + mock_create_dialog.reset_mock() + mock_create_validation.reset_mock() + + # Set to random config without name + + random_config = {"deviceClass": "Unknown"} + dialog.set_device_config(random_config) + dialog.config_validation_result = ( + dialog._device_config_template.get_config_fields(), + 0, + 0, + "", + ) + assert group_combo.currentText() == "CustomDevice" + assert variant_combo.currentText() == "CustomDevice" + with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box: + qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) + mock_warning_box.assert_called_once_with( + "Invalid Device Name", + f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + ) + mock_create_dialog.assert_not_called() + mock_create_validation.assert_not_called() def test_device_status_item(self, device_config: dict, qtbot): """Test the DeviceStatusItem widget.""" @@ -642,7 +661,9 @@ def test_run_validate_connection_action_connected( ) as mock_change_configs: # First, add device configs to the table dm_view.device_table_view.add_device_configs(device_configs) - assert mock_change_configs.call_args[0][1] is True # Configs were added + mock_change_configs.assert_called_once_with( + device_configs=device_configs, added=True, skip_validation=False + ) # Configs were added mock_change_configs.reset_mock() # Trigger the validate connection action without selection, should validate all @@ -650,7 +671,9 @@ def test_run_validate_connection_action_connected( "rerun_validation" ].action.action.triggered.emit() assert len(mock_change_configs.call_args[0][0]) == len(device_configs) - assert mock_change_configs.call_args[0][1:] == (True, True) # Configs were not added + mock_change_configs.assert_called_once_with( + device_configs, True, True + ) # Configs were added with connect=True mock_change_configs.reset_mock() # Select a single row and trigger again, should only validate that one From 6aa33cacfa8ba0b572e10921108e0da8d6a677a3 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 08:32:33 +0100 Subject: [PATCH 084/155] fix(device-manager-display-widget): Remove devices from ophyd validation after upload to BEC --- .../device_manager_view/device_manager_display_widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 15b7f0c1d..7f58ee597 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -525,7 +525,12 @@ def _update_validation_icons_after_upload(self): devices_to_update.append( (config, config_status, ConnectionStatus.CONNECTED.value, "") ) + # Update validation status in device table view self.device_table_view.update_multiple_device_validations(devices_to_update) + # Remove devices from ophyd validation view + self.ophyd_test_view.change_device_configs( + [cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True + ) @SafeSlot() def _save_to_disk_action(self): From cbdeae15a1015e04ff271d0858ed7e514688c11d Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 08:54:35 +0100 Subject: [PATCH 085/155] fix(device-manager): fix minor icon synchronization bugs --- .../device_manager_display_widget.py | 2 ++ .../components/device_table/device_table.py | 2 ++ tests/unit_tests/test_device_manager_view.py | 33 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 7f58ee597..c7690f2e5 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -531,6 +531,8 @@ def _update_validation_icons_after_upload(self): self.ophyd_test_view.change_device_configs( [cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True ) + # Config is in sync with BEC, so we update the state + self.device_table_view.device_config_in_sync_with_redis.emit(True) @SafeSlot() def _save_to_disk_action(self): diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index c930b582a..343c64387 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -978,6 +978,8 @@ def update_multiple_device_validations(self, validation_results: _ValidationResu logger.warning(f"Device {cfg.get('name')} not found in table for session update.") continue self._update_device_row_status(row, config_status, connection_status) + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.table.setSortingEnabled(True) self.set_busy(False, text="") diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index e0609d934..39e235df8 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -2,6 +2,7 @@ # pylint: disable=protected-access,redefined-outer-name +from typing import Any from unittest import mock import pytest @@ -682,3 +683,35 @@ def test_run_validate_connection_action_connected( "rerun_validation" ].action.action.triggered.emit() assert len(mock_change_configs.call_args[0][0]) == 1 + + def test_update_validation_icons_after_upload( + self, + device_manager_display_widget: DeviceManagerDisplayWidget, + device_configs: list[dict[str, Any]], + ): + """Test that validation icons are updated after uploading to Redis.""" + dm_view = device_manager_display_widget + + # Add device configs to the table + dm_view.device_table_view.add_device_configs(device_configs) + # Update the device manager devices to match what's in the table + dm_view.client.device_manager.devices = {cfg["name"]: cfg for cfg in device_configs} + + # Simulate callback + dm_view._update_validation_icons_after_upload() + + # Get validation results from the table + validation_results = dm_view.device_table_view.get_validation_results() + # Check that all devices are connected and status is updated + for dev_name, (cfg, _, connection_status) in validation_results.items(): + assert cfg in device_configs + assert connection_status == ConnectionStatus.CONNECTED.value + + # Check that no devices are in ophyd_validation widget + # Those should be all cleared after upload + cfgs = dm_view.ophyd_test_view.get_device_configs() + assert len(cfgs) == 0 + + # Check that upload config button is disabled + action = dm_view.toolbar.components.get_action("update_config_redis") + assert action.action.isEnabled() is False From 04a30ea04c80038b3a54b92403c7e579daf23350 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 09:21:11 +0100 Subject: [PATCH 086/155] refactor(ophyd-validation): Allow option to keep device visible after successful validation --- .../device_form_dialog.py | 70 +++++++++-------- .../ophyd_validation/ophyd_validation.py | 77 +++++++++++++++---- tests/unit_tests/test_device_manager_view.py | 9 ++- 3 files changed, 105 insertions(+), 51 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index c5fc18b32..08bb81f0a 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -52,6 +52,10 @@ def __init__(self, parent=None, config: dict | None = None): # type:ignore # Load and apply configuration config = config or {} + device_name = config.get("name", None) + if device_name: + self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name) + self.device_manager_ophyd_test.change_device_configs([config], True, True) # Dialog Buttons: equal size, stacked horizontally @@ -332,15 +336,17 @@ def _handle_validation_result( ): """Handle completion of validation.""" try: - if DeviceModel.model_validate(device_config) == DeviceModel.model_validate( - self._validation_result[0] + if ( + DeviceModel.model_validate(device_config) + == DeviceModel.model_validate(self._validation_result[0]) + and connection_status == ConnectionStatus.UNKNOWN.value ): - config_status = self._validation_result[1] + # Config unchanged, we can reuse previous connection status. Only do this if the new + # connection status is UNKNOWN as the current validation should not test the connection. connection_status = self._validation_result[2] - validation_msg = self._validation_result[3] except Exception: logger.debug( - f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation ." + f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation." ) self._validation_result = (device_config, config_status, connection_status, validation_msg) if self._wait_dialog is not None: @@ -351,7 +357,6 @@ def _handle_validation_result( def _add_config(self): """ - Adding a config will always run a validation check of the config without a connection test. We will check if tests have already run, and reuse the information in case they also tested the connection to the device. """ @@ -371,33 +376,34 @@ def _add_config(self): # We will show a wait dialog while this is happening, and compare the results with the last known validation results. # If the config is unchanged, we will use the connection status results from the last validation. self._wait_dialog = self._create_validation_dialog() - - ophyd_validation = self._create_and_run_ophyd_validation(config) - - # NOTE If dialog was already close, this means that a validation callback was already received - # which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above, - # this should not happen, but we keep the check for safety. - if self._wait_dialog is not None: - self._wait_dialog.exec() # This will block until the validation is complete - - config, config_status, connection_status, validation_msg = self._validation_result - - if config_status == ConfigStatus.INVALID.value: - msg_box = self._create_warning_message_box( - "Invalid Device Configuration", - f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{self._validation_result[3]}", + ophyd_validation: OphydValidation | None = None + try: + ophyd_validation = self._create_and_run_ophyd_validation(config) + + # NOTE If dialog was already closed, this means that a validation callback was already received + # which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above, + # this should not happen, but we keep the check for safety. + if self._wait_dialog is not None: + self._wait_dialog.exec() # This will block until the validation is complete + + config, config_status, connection_status, validation_msg = self._validation_result + + if config_status == ConfigStatus.INVALID.value: + msg_box = self._create_warning_message_box( + "Invalid Device Configuration", + f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{self._validation_result[3]}", + ) + msg_box.exec() + return + + self.accepted_data.emit( + config, config_status, connection_status, validation_msg, self._old_device_name ) - msg_box.exec() - ophyd_validation.close() - ophyd_validation.deleteLater() - return - - self.accepted_data.emit( - config, config_status, connection_status, validation_msg, self._old_device_name - ) - self.accept() - ophyd_validation.close() - ophyd_validation.deleteLater() + self.accept() + finally: + if ophyd_validation is not None: + ophyd_validation.close() + ophyd_validation.deleteLater() def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox: msg_box = QtWidgets.QMessageBox(self) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index eb2acfd99..af2c3058b 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -312,6 +312,7 @@ class OphydValidation(BECWidget, QtWidgets.QWidget): def __init__(self, parent=None, client=None, hide_legend: bool = False): super().__init__(parent=parent, client=client, theme_update=True) self._running_ophyd_tests = False + self._keep_visible_after_validation: list[str] = [] if not READY_TO_TEST: self.setDisabled(True) self.thread_pool_manager = None @@ -339,6 +340,25 @@ def __init__(self, parent=None, client=None, hide_legend: bool = False): self._main_layout.addWidget(legend_widget) self._thread_pool_poll_loop() + def add_device_to_keep_visible_after_validation(self, device_name: str) -> None: + """Add a device name to the list of devices to keep visible after validation. + + Args: + device_name (str): Name of the device to keep visible. + """ + if device_name not in self._keep_visible_after_validation: + self._keep_visible_after_validation.append(device_name) + + def remove_device_to_keep_visible_after_validation(self, device_name: str) -> None: + """Remove a device name from the list of devices to keep visible after validation. + + Args: + device_name (str): Name of the device to remove. + """ + if device_name in self._keep_visible_after_validation: + self._keep_visible_after_validation.remove(device_name) + self._remove_device(device_name) + def apply_theme(self, theme: str): """Apply the current theme to the widget.""" self._colors = get_accent_colors() @@ -507,6 +527,8 @@ def change_device_configs( connect (bool, optional): Whether to attempt connection during validation. Defaults to False. force_connect (bool, optional): Whether to force connection during validation. Defaults to False. timeout (float, optional): Timeout for connection attempt. Defaults to 5.0. + skip_validation (bool, optional): Whether to skip validation for the added devices. Defaults to False. + keep_device_item_in_list (bool, optional): Whether to keep the device item in the list after validation in success case. """ if not READY_TO_TEST: logger.error("Cannot change device configs: dependencies not available.") @@ -535,6 +557,20 @@ def change_device_configs( "Device already in session.", ) ) + if device_name in self._keep_visible_after_validation: + self._add_device_config( + cfg, + connect=connect, + force_connect=force_connect, + timeout=timeout, + skip_validation=True, + ) + self._on_device_test_completed( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) self._remove_device_config(cfg) continue if not self._device_already_exists(cfg.get("name")): # New device case @@ -596,7 +632,12 @@ def _device_already_exists(self, device_name: str) -> bool: return device_name in self.list_widget def _add_device_config( - self, device_config: dict[str, Any], connect: bool, force_connect: bool, timeout: float + self, + device_config: dict[str, Any], + connect: bool, + force_connect: bool, + timeout: float, + skip_validation: bool = False, ) -> None: device_name = device_config.get("name") # Check if device is in redis session with same config, if yes don't even bother testing.. @@ -611,22 +652,28 @@ def _add_device_config( ) widget.request_rerun_validation.connect(self._on_request_rerun_validation) self.list_widget.add_widget_item(device_name, widget) - self.__delayed_submit_test(widget, connect, force_connect, timeout) + if skip_validation is False: + self.__delayed_submit_test(widget, connect, force_connect, timeout) - def _remove_device_config(self, device_config: dict[str, Any]) -> None: - device_name = device_config.get("name") - if not device_name: - logger.error(f"Device config missing 'name': {device_config}. Cannot remove device.") - return + def _remove_device(self, device_name: str) -> None: if not self._device_already_exists(device_name): logger.debug( f"Device with name {device_name} not found in OphydValidation, can't remove it." ) return + if device_name in self._keep_visible_after_validation: + logger.debug( + f"Device with name {device_name} is set to be kept visible after validation, not removing it." + ) + return if self.thread_pool_manager: self.thread_pool_manager.clear_device_in_queue(device_name) self.list_widget.remove_widget_item(device_name) + def _remove_device_config(self, device_config: dict[str, Any]) -> None: + device_name = device_config.get("name") + self._remove_device(device_name) + @SafeSlot(str, dict, bool, bool, float) def _on_request_rerun_validation( self, @@ -712,16 +759,6 @@ def _on_device_test_completed( if not self._device_already_exists(device_name): logger.debug(f"Received test result for unknown device {device_name}. Ignoring.") return - if config_status == ConfigStatus.VALID.value and connection_status in [ - ConnectionStatus.CONNECTED.value, - ConnectionStatus.CAN_CONNECT.value, - ]: - # Validated successfully, remove item from running list - self.list_widget.remove_widget_item(device_name) - self.validation_completed.emit( - device_config, config_status, connection_status, error_message - ) - return widget = self.list_widget.get_widget(device_name) if widget: widget.on_validation_finished( @@ -729,6 +766,12 @@ def _on_device_test_completed( config_status=config_status, connection_status=connection_status, ) + if config_status == ConfigStatus.VALID.value and connection_status in [ + ConnectionStatus.CONNECTED.value, + ConnectionStatus.CAN_CONNECT.value, + ]: + # Validated successfully, remove item from running list + self._remove_device(device_name) self.validation_completed.emit( device_config, config_status, connection_status, error_message ) diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 39e235df8..e822d1e96 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -652,7 +652,7 @@ def test_add_device_action_connected( mock_add.assert_called_once() def test_run_validate_connection_action_connected( - self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict + self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict, qtbot ): """Test run validate connection action is connected.""" dm_view = device_manager_display_widget @@ -661,7 +661,12 @@ def test_run_validate_connection_action_connected( dm_view.ophyd_test_view, "change_device_configs" ) as mock_change_configs: # First, add device configs to the table - dm_view.device_table_view.add_device_configs(device_configs) + with qtbot.waitSignal(dm_view.device_table_view.device_configs_changed) as sig_blocker: + dm_view.device_table_view.add_device_configs(device_configs) + cfgs, added, skip_validation = sig_blocker.args + assert cfgs == device_configs + assert added is True + assert skip_validation is False mock_change_configs.assert_called_once_with( device_configs=device_configs, added=True, skip_validation=False ) # Configs were added From f71c8c882f0c717aa69cacffc843514e4175b67b Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 11:32:02 +0100 Subject: [PATCH 087/155] test(device-manager): use mocked client for tests --- .../device_form_dialog.py | 1 + .../upload_redis_dialog.py | 112 ++++++---------- .../device_manager_display_widget.py | 19 ++- bec_widgets/tests/utils.py | 8 ++ .../components/device_table/device_table.py | 13 +- .../ophyd_validation/ophyd_validation.py | 12 +- .../test_device_manager_components.py | 51 +++++++- tests/unit_tests/test_device_manager_view.py | 121 +++++++++++++----- 8 files changed, 218 insertions(+), 119 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 08bb81f0a..9d57daf0e 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -344,6 +344,7 @@ def _handle_validation_result( # Config unchanged, we can reuse previous connection status. Only do this if the new # connection status is UNKNOWN as the current validation should not test the connection. connection_status = self._validation_result[2] + validation_msg = self._validation_result[3] except Exception: logger.debug( f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation." diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py index cb6f52b3a..e4e746df5 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py @@ -4,7 +4,7 @@ from enum import IntEnum from functools import partial -from typing import TYPE_CHECKING, Dict, List, Tuple +from typing import TYPE_CHECKING, List, Tuple from bec_lib.logger import bec_logger from bec_qthemes import apply_theme, material_icon @@ -12,16 +12,17 @@ from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.control.device_manager.components import OphydValidation from bec_widgets.widgets.control.device_manager.components.ophyd_validation import ( ConfigStatus, ConnectionStatus, get_validation_icons, ) -from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar if TYPE_CHECKING: from bec_widgets.utils.colors import AccentColor + from bec_widgets.widgets.control.device_manager.components.device_table.device_table import ( + _ValidationResultIter, + ) logger = bec_logger.logger @@ -234,22 +235,18 @@ class UploadRedisDialog(QtWidgets.QDialog): class UploadAction(IntEnum): """Enum for upload actions.""" - CANCEL = QtWidgets.QDialog.Rejected - OK = QtWidgets.QDialog.Accepted + CANCEL = QtWidgets.QDialog.DialogCode.Rejected + OK = QtWidgets.QDialog.DialogCode.Accepted + CONNECTION_TEST_REQUESTED = 999 - # Signal to trigger upload after confirmation - upload_confirmed = QtCore.Signal(int) + # Request ophyd validation for all untested device connections + # list of device configs, added: bool, connect: bool + request_ophyd_validation = QtCore.Signal(list, bool, bool) - def __init__( - self, - parent, - ophyd_test_widget: OphydValidation, - device_configs: dict[str, Tuple[dict, int, int]] | None = None, - ): + def __init__(self, parent, device_configs: dict[str, Tuple[dict, int, int]] | None = None): super().__init__(parent=parent) self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {} - self.ophyd_test_widget = ophyd_test_widget self._transparent_button_style = "background-color: transparent; border: none;" self.colors = get_accent_colors() @@ -267,14 +264,9 @@ def __init__( self.has_invalid_configs: int = 0 self.has_untested_connections: int = 0 self.has_cannot_connect: int = 0 - self._current_progress: int | None = None self._setup_ui() self._update_ui() - # Disable validation features if no ophyd test widget provided, else connect validation - self._validation_connection = self.ophyd_test_widget.validation_completed.connect( - self._update_from_ophyd_device_tests - ) def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]): """ @@ -288,18 +280,6 @@ def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]): self.device_configs = device_configs self._update_ui() - def accept(self): - self.cleanup() - return super().accept() - - def reject(self): - self.cleanup() - return super().reject() - - def cleanup(self): - """Cleanup on dialog finish.""" - self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection) - def _setup_ui(self): """Setup the main UI for the dialog.""" self.setWindowTitle("Upload Configuration to BEC Server") @@ -347,11 +327,6 @@ def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout): button_layout.addWidget(self.validate_connections_btn) button_layout.addStretch() button_layout.addSpacing(16) - - # Progress bar - self._progress_bar = BECProgressBar(self) - self._progress_bar.setVisible(False) - button_layout.addWidget(self._progress_bar) action_layout.addLayout(button_layout) # Status indicator @@ -498,7 +473,7 @@ def _update_ui(self): @SafeSlot() def _validate_connections(self): - """Request validation of all untested connections.""" + """Request validation of all untested connections. This will close the dialog.""" testable_devices: List[dict] = [] for _, (config, _, connection_status) in self.device_configs.items(): if connection_status == ConnectionStatus.UNKNOWN.value: @@ -507,13 +482,8 @@ def _validate_connections(self): testable_devices.append(config) if len(testable_devices) > 0: - self.validate_connections_btn.setEnabled(False) - self._progress_bar.setVisible(True) - self._progress_bar.maximum = len(testable_devices) - self._progress_bar.minimum = 0 - self._progress_bar.set_value(0) - self._current_progress = 0 - self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True) + self.request_ophyd_validation.emit(testable_devices, True, True) + self.done(self.UploadAction.CONNECTION_TEST_REQUESTED) @SafeSlot() def _handle_upload(self): @@ -611,35 +581,40 @@ def _update_from_ophyd_device_tests( return self.update_device_status(device_config, config_status, connection_status) + @SafeSlot(list) + def _multiple_updates_from_ophyd_device_tests(self, validation_results: _ValidationResultIter): + """ + Callback slot for receiving multiple validation result updates from the ophyd test widget. + + Args: + validation_results (list): List of tuples containing (device_config, config_status, connection_status, validation_msg). + """ + for cfg, cfg_status, conn_status, val_msg in validation_results: + self.update_device_status(cfg, cfg_status, conn_status) + self._update_ui() + @SafeSlot(dict, int, int) def update_device_status(self, device_config: dict, config_status: int, connection_status: int): """Update the status of a specific device.""" # Update device config status + self._update_device_configs(device_config, config_status, connection_status, "") + # Recalculate summaries and UI state + self._update_ui() + + def _update_device_configs( + self, + device_config: dict[str, Any], + config_status: int, + connection_status: int, + validation_msg: str, + ): device_name = device_config.get("name", "") old_config, _, _ = self.device_configs.get(device_name, (None, None, None)) if old_config is not None: self.device_configs[device_name] = (device_config, config_status, connection_status) - if self._current_progress is not None: - self._current_progress += 1 - self._progress_bar.set_value(self._current_progress) - if self._current_progress >= self._progress_bar.maximum: - self._progress_bar.setVisible(False) - self._progress_bar.set_value(0) - self._current_progress = None - self.validation_completed() - self._update_ui() - return - - # Update UI sections - self.config_section.add_device(device_config, config_status, connection_status) - - # Recalculate summaries and UI state - self._update_ui() - - def validation_completed(self): - """Called when connection validation is completed.""" - self.validate_connections_btn.setEnabled(True) - self._update_ui() + else: + # If device not found, add it + self.config_section.add_device(device_config, config_status, connection_status) def main(): # pragma: no cover @@ -705,12 +680,7 @@ def main(): # pragma: no cover ] configs = {cfg[0]["name"]: cfg for cfg in sample_configs} apply_theme("dark") - from unittest import mock - - ophyd_test_widget = mock.MagicMock(spec=OphydValidation) - dialog = UploadRedisDialog( - parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget - ) + dialog = UploadRedisDialog(parent=None, device_configs=configs) dialog.show() sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index c7690f2e5..8c3886645 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -35,6 +35,7 @@ ) from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import ( + ConfigStatus, ConnectionStatus, ) from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( @@ -57,7 +58,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget): request_ophyd_validation = Signal(list, bool, bool) - def __init__(self, parent=None, client=None, *args, **kwargs): + def __init__(self, parent=None, *args, **kwargs): super().__init__(parent=parent, variant="compact", *args, **kwargs) # Push to Redis dialog @@ -312,6 +313,13 @@ def _run_validate_connection(self, connect: bool = True): configs = list(self.device_table_view.get_selected_device_configs()) if not configs: configs = self.device_table_view.get_device_config() + # Adjust the state of the icons in the device table view + self.device_table_view.update_multiple_device_validations( + [ + (cfg, ConfigStatus.UNKNOWN.value, ConnectionStatus.UNKNOWN.value, "") + for cfg in configs + ] + ) self.request_ophyd_validation.emit(configs, True, connect) def _update_config_enabled_button(self, enabled: bool): @@ -474,7 +482,10 @@ def _update_redis_action(self) -> None | QMessageBox.StandardButton: validation_results = self.device_table_view.get_validation_results() # Create and show upload dialog self._upload_redis_dialog = UploadRedisDialog( - parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view + parent=self, device_configs=validation_results + ) + self._upload_redis_dialog.request_ophyd_validation.connect( + self.request_ophyd_validation.emit ) # Show dialog @@ -484,6 +495,10 @@ def _update_redis_action(self) -> None | QMessageBox.StandardButton: self._push_composition_to_redis(action="set") elif reply == UploadRedisDialog.UploadAction.CANCEL: self.ophyd_test_view.cancel_all_validations() + elif reply == UploadRedisDialog.UploadAction.CONNECTION_TEST_REQUESTED: + return QMessageBox.information( + self, "Connection Test Requested", "Running connection test on untested devices." + ) def _push_composition_to_redis(self, action: ConfigAction): """Push the current device composition to Redis.""" diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index bf4cdf0e2..d9fd7d436 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -1,3 +1,4 @@ +# pylint: skip-file from unittest.mock import MagicMock from bec_lib.device import Device as BECDevice @@ -255,6 +256,13 @@ def get_bec_signals(self, signal_class_name: str): signals.append((device_name, signal_name, signal_info)) return signals + def _get_redis_device_config(self) -> list[dict]: + """Mock method to emulate DeviceManager._get_redis_device_config.""" + configs = [] + for device in self.devices.values(): + configs.append(device._config) + return configs + DEVICES = [ FakePositioner("samx", limits=[-10, 10], read_value=2.0), diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index 343c64387..7f2edb328 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -210,8 +210,8 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): _auto_size_request = QtCore.Signal() - def __init__(self, parent: QtWidgets.QWidget | None = None): - super().__init__(parent=parent) + def __init__(self, parent: QtWidgets.QWidget | None = None, client=None): + super().__init__(parent=parent, client=client) self.headers_key_map: dict[str, str] = { "Valid": "valid", "Connect": "connect", @@ -823,13 +823,14 @@ def get_selected_device_configs(self) -> list[dict]: # Public API to be called via signals/slots # ------------------------------------------------------------------------- - @SafeSlot(list) + @SafeSlot(list, bool) def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ Set the device config. This will clear any existing configs. Args: device_configs (Iterable[dict[str, Any]]): The device configs to set. + skip_validation (bool): Whether to skip validation for the set devices. """ self.set_busy(True, text="Loading device configurations...") with self.table_sort_on_hold: @@ -857,7 +858,7 @@ def clear_device_configs(self): self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") - @SafeSlot(list) + @SafeSlot(list, bool) def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ Add devices to the config. If a device already exists, it will be replaced. If the validation is @@ -866,6 +867,7 @@ def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bo Args: device_configs (Iterable[dict[str, Any]]): The device configs to add. + skip_validation (bool): Whether to skip validation for the added devices. """ self.set_busy(True, text="Adding device configurations...") already_in_table = [] @@ -894,13 +896,14 @@ def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bo self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.set_busy(False, text="") - @SafeSlot(list) + @SafeSlot(list, bool) def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): """ Update devices in the config. If a device does not exist, it will be added. Args: device_configs (Iterable[dict[str, Any]]): The device configs to update. + skip_validation (bool): Whether to skip validation for the updated devices. """ self.set_busy(True, text="Loading device configurations...") cfgs_updated = [] diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index af2c3058b..5ff9d978b 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -494,7 +494,14 @@ def get_device_configs(self) -> list[dict[str, Any]]: def device_table_config_changed( self, device_configs: list[dict[str, Any]], added: bool, skip_validation: bool ) -> None: - """Slot to handle device config changes in the device table.""" + """ + Slot to handle device config changes in the device table. + + Args: + device_configs (list[dict[str, Any]]): List of device configurations. + added (bool): Whether the devices are added to the existing list. + skip_validation (bool): Whether to skip validation for the added devices. + """ self.change_device_configs( device_configs=device_configs, added=added, skip_validation=skip_validation ) @@ -528,7 +535,6 @@ def change_device_configs( force_connect (bool, optional): Whether to force connection during validation. Defaults to False. timeout (float, optional): Timeout for connection attempt. Defaults to 5.0. skip_validation (bool, optional): Whether to skip validation for the added devices. Defaults to False. - keep_device_item_in_list (bool, optional): Whether to keep the device item in the list after validation in success case. """ if not READY_TO_TEST: logger.error("Cannot change device configs: dependencies not available.") @@ -652,7 +658,7 @@ def _add_device_config( ) widget.request_rerun_validation.connect(self._on_request_rerun_validation) self.list_widget.add_widget_item(device_name, widget) - if skip_validation is False: + if not skip_validation: self.__delayed_submit_test(widget, connect, force_connect, timeout) def _remove_device(self, device_name: str) -> None: diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index a690d059d..80c31b6b5 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -57,6 +57,8 @@ ) from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +from .client_mocks import mocked_client + class TestConstants: """Test class for constants and configuration values.""" @@ -296,9 +298,9 @@ class TestDeviceTable: """Test class for DeviceTable component.""" @pytest.fixture - def device_table(self, qtbot) -> Generator[DeviceTable, None, None]: + def device_table(self, qtbot, mocked_client) -> Generator[DeviceTable, None, None]: """Fixture to create a DeviceTable instance.""" - table = DeviceTable() + table = DeviceTable(client=mocked_client) qtbot.addWidget(table) qtbot.waitExposed(table) yield table @@ -997,7 +999,7 @@ def test_legend_label(self, legend_label: LegendLabel): assert label.text() == "Connect Legend:" @pytest.fixture - def ophyd_test(self, qtbot): + def ophyd_test(self, qtbot, mocked_client): """Fixture to create an OphydValidation instance. We patch the method that starts the polling loop to avoid side effects.""" with ( mock.patch( @@ -1009,7 +1011,7 @@ def ophyd_test(self, qtbot): return_value=False, ), ): - widget = OphydValidation() + widget = OphydValidation(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -1034,6 +1036,47 @@ def _stop_validation_button_clicked(): qtbot.mouseClick(ophyd_test._stop_validation_button, QtCore.Qt.LeftButton) assert click_event.is_set() + def test_ophyd_test_keep_visible_after_validation(self, ophyd_test: OphydValidation, qtbot): + """Test the keep visible after validation logic.""" + # Initially false + assert len(ophyd_test._keep_visible_after_validation) == 0 + + # Add device to keep visible + ophyd_test.add_device_to_keep_visible_after_validation("device_1") + assert "device_1" in ophyd_test._keep_visible_after_validation + # Add second device + ophyd_test.add_device_to_keep_visible_after_validation("device_2") + assert "device_2" in ophyd_test._keep_visible_after_validation + assert len(ophyd_test._keep_visible_after_validation) == 2 + + # Remove device + ophyd_test.remove_device_to_keep_visible_after_validation("device_1") + assert "device_1" not in ophyd_test._keep_visible_after_validation + assert "device_2" in ophyd_test._keep_visible_after_validation + + # Change config with skip validation and device in keep visible list + with ( + mock.patch.object( + ophyd_test, "_is_device_in_redis_session", return_value=True + ) as mock_is_device_in_redis_session, + mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config, + mock.patch.object( + ophyd_test, "_on_device_test_completed" + ) as mock_on_device_test_completed, + ): + ophyd_test.change_device_configs( + [{"name": "device_2", "deviceClass": "TestClass"}], + added=True, + skip_validation=False, + ) + mock_add_device_config.assert_called_once() + mock_on_device_test_completed.assert_called_once_with( + {"name": "device_2", "deviceClass": "TestClass"}, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) + def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): """Test adding devices to OphydValidation widget.""" sample_devices = [ diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index e822d1e96..214855e2c 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -41,6 +41,8 @@ OphydValidation, ) +from .client_mocks import mocked_client + @pytest.fixture def device_config() -> dict: @@ -164,6 +166,67 @@ def test_device_form_dialog(self, device_form_dialog: DeviceFormDialog, qtbot): connection_settings_layout.count() == fields_in_config * 2 ) # Each field has a label and a widget + def test_device_form_dialog_help_methods( + self, device_form_dialog: DeviceFormDialog, device_config, qtbot + ): + """Test help methods in DeviceFormDialog.""" + # Test handle devices already in session results + dialog = device_form_dialog + + # Test _handle_devices_already_in_session_results + with mock.patch.object(dialog, "_handle_validation_result") as mock_handle_validation: + dialog._handle_devices_already_in_session_results([(device_config, 0, 0, "")]) + mock_handle_validation.assert_called_once_with(device_config, 0, 0, "") + mock_handle_validation.reset_mock() + dialog._handle_devices_already_in_session_results([]) + mock_handle_validation.assert_not_called() + mock_handle_validation.reset_mock() + dialog._handle_devices_already_in_session_results( + [(device_config, 1, 0, ""), (device_config, 0, 0, "")] + ) + mock_handle_validation.assert_called_once_with( + device_config, 1, 0, "" + ) # Should be called with first + + # Test _handle_validation_result + # I. No wait dialog present + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog._validation_result == (device_config, 1, 3, "All good") + + # II. No previous validation, but wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 3, "All good") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + + mock_wait_dialog.reset_mock() + assert dialog._wait_dialog is None + + # III. Previous validation present and the same config, wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + dialog._validation_result = (device_config, 1, 1, "Previous bad") + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 1, "Previous bad") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + + mock_wait_dialog.reset_mock() + assert dialog._wait_dialog is None + + # IV. Previous validation present but different config, wait dialog present + with mock.patch.object(dialog, "_wait_dialog") as mock_wait_dialog: + different_config = device_config.copy() + different_config["deviceClass"] = "DifferentClass" + dialog._validation_result = (different_config, 1, 1, "Previous bad") + dialog._handle_validation_result(device_config, 1, 3, "All good") + assert dialog.config_validation_result == (device_config, 1, 3, "All good") + mock_wait_dialog.accept.assert_called_once() + mock_wait_dialog.close.assert_called_once() + mock_wait_dialog.deleteLater.assert_called_once() + def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): """Test setting device configuration in DeviceFormDialog.""" dialog = device_form_dialog @@ -330,9 +393,7 @@ def device_configs_unknown(self, device_config: dict): @pytest.fixture def upload_redis_dialog(self, qtbot): """Fixture for UploadRedisDialog.""" - dialog = UploadRedisDialog( - parent=None, ophyd_test_widget=mock.MagicMock(spec=OphydValidation), device_configs={} - ) + dialog = UploadRedisDialog(parent=None, device_configs={}) try: qtbot.addWidget(dialog) qtbot.waitExposed(dialog) @@ -460,37 +521,16 @@ def test_upload_redis_invalid_config( assert dialog.config_section.summary_label.text() == expected_text - def test_upload_redis_validate_connections(self, device_configs_invalid, qtbot): - """Test the validate connections method in UploadRedisDialog.""" - configs = device_configs_invalid - ophyd_test_mock = mock.MagicMock(spec=OphydValidation) - try: - dialog = UploadRedisDialog( - parent=None, ophyd_test_widget=ophyd_test_mock, device_configs=configs - ) - qtbot.addWidget(dialog) - qtbot.waitExposed(dialog) - - with mock.patch.object( - dialog.ophyd_test_widget, "change_device_configs" - ) as mock_change: - dialog._validate_connections() - mock_change.assert_called_once_with( - [cfg for k, (cfg, _, _) in configs.items() if k in ["Device_0", "Device_3"]], - added=True, - connect=True, - ) - finally: - dialog.close() - class TestDeviceManagerView: """Test class for DeviceManagerView functionality.""" @pytest.fixture - def dm_view(self, qtbot): + def dm_view(self, qtbot, mocked_client): """Fixture for DeviceManagerView.""" widget = DeviceManagerView() + # Assign the mocked client + widget.device_manager_widget.client = mocked_client qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -513,7 +553,6 @@ def test_dm_view_initialization(self, dm_view, qtbot): # Reset for test loading current config dm_widget._initialized = False dm_widget.stacked_layout.setCurrentWidget(dm_widget._overlay_widget) - dm_widget.client.device_manager = mock.MagicMock() with mock.patch.object( dm_widget.client.device_manager, "_get_redis_device_config" @@ -532,12 +571,26 @@ def test_dm_view_initialization(self, dm_view, qtbot): mock_set.assert_called_once_with([]) @pytest.fixture - def device_manager_display_widget(self, qtbot): - """Fixture for DeviceManagerDisplayWidget within DeviceManagerView.""" - widget = DeviceManagerDisplayWidget() - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget + def device_manager_display_widget(self, qtbot, mocked_client): + """Fixture for DeviceManagerDisplayWidget within DeviceManagerView. + We will patch the OphydValidation _thread_pool_poll_loop to avoid starting threads during tests, + and the _is_device_in_redis_session method to avoid Redis dependencies + """ + + with ( + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop", + return_value=None, + ), + mock.patch( + "bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session", + return_value=False, + ), + ): + widget = DeviceManagerDisplayWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget @pytest.fixture def device_configs(self, device_config: dict): From 3926c5c947dd5525a0db864d9588afa650bdc759 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 23 Dec 2025 11:07:34 +0100 Subject: [PATCH 088/155] feat(web console): add support for shared web console sessions --- .github/workflows/end2end-conda.yml | 17 +- .../views/developer_view/developer_widget.py | 9 +- bec_widgets/cli/client.py | 23 + .../advanced_dock_area/advanced_dock_area.py | 9 +- .../editors/web_console/bec_shell.pyproject | 1 + .../editors/web_console/bec_shell_plugin.py | 57 +++ .../editors/web_console/register_bec_shell.py | 15 + .../editors/web_console/web_console.py | 452 +++++++++++++++++- tests/unit_tests/test_advanced_dock_area.py | 2 +- tests/unit_tests/test_web_console.py | 347 +++++++++++++- 10 files changed, 883 insertions(+), 49 deletions(-) create mode 100644 bec_widgets/widgets/editors/web_console/bec_shell.pyproject create mode 100644 bec_widgets/widgets/editors/web_console/bec_shell_plugin.py create mode 100644 bec_widgets/widgets/editors/web_console/register_bec_shell.py diff --git a/.github/workflows/end2end-conda.yml b/.github/workflows/end2end-conda.yml index 65c644fd5..432903f1d 100644 --- a/.github/workflows/end2end-conda.yml +++ b/.github/workflows/end2end-conda.yml @@ -9,10 +9,10 @@ jobs: shell: bash -el {0} env: - CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices - BEC_CORE_BRANCH: main # Set the branch you want for bec - OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices - PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo + CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices + BEC_CORE_BRANCH: main # Set the branch you want for bec + OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices + PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo PROJECT_PATH: ${{ github.repository }} QTWEBENGINE_DISABLE_SANDBOX: 1 QT_QPA_PLATFORM: "offscreen" @@ -23,15 +23,16 @@ jobs: - name: Set up Conda uses: conda-incubator/setup-miniconda@v3 with: - auto-update-conda: true - auto-activate-base: true - python-version: '3.11' + auto-update-conda: true + auto-activate-base: true + python-version: "3.11" - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1 + sudo apt-get -y install ttyd - name: Conda install and run pytest run: | @@ -55,4 +56,4 @@ jobs: with: name: pytest-logs path: ./logs/*.log - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 76f482bca..8e4f42f18 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -18,7 +18,7 @@ from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget -from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -91,9 +91,10 @@ def __init__(self, parent=None, **kwargs): # Initialize the widgets self.explorer = IDEExplorer(self) self.explorer.setObjectName("Explorer") - self.console = WebConsole(self) - self.console.setObjectName("Console") - self.terminal = WebConsole(self, startup_cmd="") + + self.console = BECShell(self) + self.console.setObjectName("BEC Shell") + self.terminal = WebConsole(self) self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) self.monaco.setObjectName("MonacoEditor") diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 6fd5dd0cb..7ae6767f0 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -30,6 +30,7 @@ class _WidgetsEnumType(str, enum.Enum): "BECMainWindow": "BECMainWindow", "BECProgressBar": "BECProgressBar", "BECQueue": "BECQueue", + "BECShell": "BECShell", "BECStatusBox": "BECStatusBox", "DapComboBox": "DapComboBox", "DarkModeButton": "DarkModeButton", @@ -495,6 +496,28 @@ def detach(self): """ +class BECShell(RPCBase): + """A WebConsole pre-configured to run the BEC shell.""" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class BECStatusBox(RPCBase): """An autonomous widget to display the status of BEC services.""" diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index c9a4812e9..4955b0da2 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -70,7 +70,7 @@ from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D from bec_widgets.widgets.control.scan_control import ScanControl -from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap @@ -378,7 +378,7 @@ def _setup_toolbar(self): "RingProgressBar", ), "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), - "bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"), + "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), } @@ -501,10 +501,7 @@ def _connect_menu(menu_key: str): elif key == "bec_shell": act.triggered.connect( lambda _, t=widget_type: self.new( - widget=t, - closable=True, - startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", - show_settings_action=True, + widget=t, closable=True, show_settings_action=False ) ) else: diff --git a/bec_widgets/widgets/editors/web_console/bec_shell.pyproject b/bec_widgets/widgets/editors/web_console/bec_shell.pyproject new file mode 100644 index 000000000..786a751fe --- /dev/null +++ b/bec_widgets/widgets/editors/web_console/bec_shell.pyproject @@ -0,0 +1 @@ +{'files': ['web_console.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py b/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py new file mode 100644 index 000000000..92112c39f --- /dev/null +++ b/bec_widgets/widgets/editors/web_console/bec_shell_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.editors.web_console.web_console import BECShell + +DOM_XML = """ + + + + +""" + + +class BECShellPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = BECShell(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(BECShell.ICON_NAME) + + def includeFile(self): + return "bec_shell" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "BECShell" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/editors/web_console/register_bec_shell.py b/bec_widgets/widgets/editors/web_console/register_bec_shell.py new file mode 100644 index 000000000..3e5562989 --- /dev/null +++ b/bec_widgets/widgets/editors/web_console/register_bec_shell.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/editors/web_console/web_console.py b/bec_widgets/widgets/editors/web_console/web_console.py index 62eede57e..c7ca75da8 100644 --- a/bec_widgets/widgets/editors/web_console/web_console.py +++ b/bec_widgets/widgets/editors/web_console/web_console.py @@ -1,21 +1,39 @@ from __future__ import annotations +import enum +import json import secrets import subprocess import time from bec_lib.logger import bec_logger from louie.saferef import safe_ref -from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler +from pydantic import BaseModel +from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler +from qtpy.QtGui import QMouseEvent, QResizeEvent from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeProperty logger = bec_logger.logger +class ConsoleMode(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + HIDDEN = "hidden" + + +class PageOwnerInfo(BaseModel): + owner_gui_id: str | None = None + widget_ids: list[str] = [] + page: QWebEnginePage | None = None + initialized: bool = False + + model_config = {"arbitrary_types_allowed": True} + + class WebConsoleRegistry: """ A registry for the WebConsole class to manage its instances. @@ -29,14 +47,21 @@ def __init__(self): self._server_process = None self._server_port = None self._token = secrets.token_hex(16) + self._page_registry: dict[str, PageOwnerInfo] = {} def register(self, instance: WebConsole): """ Register an instance of WebConsole. + + Args: + instance (WebConsole): The instance to register. """ self._instances[instance.gui_id] = safe_ref(instance) self.cleanup() + if instance._unique_id: + self._register_page(instance) + if self._server_process is None: # Start the ttyd server if not already running self.start_ttyd() @@ -141,8 +166,158 @@ def unregister(self, instance: WebConsole): if instance.gui_id in self._instances: del self._instances[instance.gui_id] + if instance._unique_id: + self._unregister_page(instance._unique_id, instance.gui_id) + self.cleanup() + def _register_page(self, instance: WebConsole): + """ + Register a page in the registry. Please note that this does not transfer ownership + for already existing pages; it simply records which widget currently owns the page. + Use transfer_page_ownership to change ownership. + + Args: + instance (WebConsole): The instance to register. + """ + + unique_id = instance._unique_id + gui_id = instance.gui_id + + if unique_id is None: + return + + if unique_id not in self._page_registry: + page = BECWebEnginePage() + page.authenticationRequired.connect(instance._authenticate) + self._page_registry[unique_id] = PageOwnerInfo( + owner_gui_id=gui_id, widget_ids=[gui_id], page=page + ) + logger.info(f"Registered new page {unique_id} for {gui_id}") + return + + if gui_id not in self._page_registry[unique_id].widget_ids: + self._page_registry[unique_id].widget_ids.append(gui_id) + + def _unregister_page(self, unique_id: str, gui_id: str): + """ + Unregister a page from the registry. + + Args: + unique_id (str): The unique identifier for the page. + gui_id (str): The GUI ID of the widget. + """ + if unique_id not in self._page_registry: + return + page_info = self._page_registry[unique_id] + if gui_id in page_info.widget_ids: + page_info.widget_ids.remove(gui_id) + if page_info.owner_gui_id == gui_id: + page_info.owner_gui_id = None + if not page_info.widget_ids: + if page_info.page: + page_info.page.deleteLater() + del self._page_registry[unique_id] + + logger.info(f"Unregistered page {unique_id} for {gui_id}") + + def get_page_info(self, unique_id: str) -> PageOwnerInfo | None: + """ + Get a page from the registry. + + Args: + unique_id (str): The unique identifier for the page. + + Returns: + PageOwnerInfo | None: The page info if found, None otherwise. + """ + if unique_id not in self._page_registry: + return None + return self._page_registry[unique_id] + + def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None: + """ + Transfer ownership of a page to a new owner. + + Args: + unique_id (str): The unique identifier for the page. + new_owner_gui_id (str): The GUI ID of the new owner. + + Returns: + QWebEnginePage | None: The page if ownership transfer was successful, None otherwise. + """ + if unique_id not in self._page_registry: + logger.warning(f"Page {unique_id} not found in registry") + return None + + page_info = self._page_registry[unique_id] + old_owner_gui_id = page_info.owner_gui_id + if old_owner_gui_id: + old_owner_ref = self._instances.get(old_owner_gui_id) + if old_owner_ref: + old_owner_instance = old_owner_ref() + if old_owner_instance: + old_owner_instance.yield_ownership() + page_info.owner_gui_id = new_owner_gui_id + + logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}") + return page_info.page + + def yield_ownership(self, gui_id: str) -> bool: + """ + Yield ownership of a page without destroying it. The page remains in the + registry with no owner, available for another widget to claim. + + Args: + gui_id (str): The GUI ID of the widget yielding ownership. + + Returns: + bool: True if ownership was yielded, False otherwise. + """ + if gui_id not in self._instances: + return False + + instance = self._instances[gui_id]() + if instance is None: + return False + + unique_id = instance._unique_id + if unique_id is None: + return False + + if unique_id not in self._page_registry: + return False + + page_owner_info = self._page_registry[unique_id] + if page_owner_info.owner_gui_id != gui_id: + return False + + page_owner_info.owner_gui_id = None + return True + + def owner_is_visible(self, unique_id: str) -> bool: + """ + Check if the owner of a page is currently visible. + + Args: + unique_id (str): The unique identifier for the page. + Returns: + bool: True if the owner is visible, False otherwise. + """ + page_info = self.get_page_info(unique_id) + if page_info is None or page_info.owner_gui_id is None: + return False + + owner_ref = self._instances.get(page_info.owner_gui_id) + if owner_ref is None: + return False + + owner_instance = owner_ref() + if owner_instance is None: + return False + + return owner_instance.isVisible() + _web_console_registry = WebConsoleRegistry() @@ -178,34 +353,103 @@ def __init__( config=None, client=None, gui_id=None, - startup_cmd: str | None = "bec --nogui", + startup_cmd: str | None = None, + is_bec_shell: bool = False, + unique_id: str | None = None, **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) + self._mode = ConsoleMode.INACTIVE + self._is_bec_shell = is_bec_shell self._startup_cmd = startup_cmd self._is_initialized = False - _web_console_registry.register(self) - self._token = _web_console_registry._token - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.browser = QWebEngineView(self) - self.page = BECWebEnginePage(self) - self.page.authenticationRequired.connect(self._authenticate) - self.browser.setPage(self.page) - layout.addWidget(self.browser) - self.setLayout(layout) - self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + self._unique_id = unique_id + self.page = None # Will be set in _set_up_page + self._startup_timer = QTimer() self._startup_timer.setInterval(500) self._startup_timer.timeout.connect(self._check_page_ready) self._startup_timer.start() self._js_callback.connect(self._on_js_callback) + self._set_up_page() + + def _set_up_page(self): + """ + Set up the web page and UI elements. + """ + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.browser = QWebEngineView(self) + + layout.addWidget(self.browser) + self.setLayout(layout) + + # prepare overlay + self.overlay = QWidget(self) + layout = QVBoxLayout(self.overlay) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + label = QLabel("Click to activate terminal", self.overlay) + layout.addWidget(label) + self.overlay.hide() + + _web_console_registry.register(self) + self._token = _web_console_registry._token + + # If no unique_id is provided, create a new page + if not self._unique_id: + self.page = BECWebEnginePage(self) + self.page.authenticationRequired.connect(self._authenticate) + self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + self.browser.setPage(self.page) + self._set_mode(ConsoleMode.ACTIVE) + return + + # Try to get the page from the registry + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info and page_info.page: + self.page = page_info.page + if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id: + self.browser.setPage(self.page) + # Only set URL if this is a newly created page (no URL set yet) + if self.page.url().isEmpty(): + self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}")) + else: + # We have an existing page, so we don't need the startup timer + self._startup_timer.stop() + if page_info.owner_gui_id != self.gui_id: + self._set_mode(ConsoleMode.INACTIVE) + else: + self._set_mode(ConsoleMode.ACTIVE) + + def _set_mode(self, mode: ConsoleMode): + """ + Set the mode of the web console. + + Args: + mode (ConsoleMode): The mode to set. + """ + if not self._unique_id: + # For non-unique_id consoles, always active + mode = ConsoleMode.ACTIVE + + self._mode = mode + match mode: + case ConsoleMode.ACTIVE: + self.browser.setVisible(True) + self.overlay.hide() + case ConsoleMode.INACTIVE: + self.browser.setVisible(False) + self.overlay.show() + case ConsoleMode.HIDDEN: + self.browser.setVisible(False) + self.overlay.hide() + def _check_page_ready(self): """ Check if the page is ready and stop the timer if it is. """ - if self.page.isLoading(): + if not self.page or self.page.isLoading(): return self.page.runJavaScript("window.term !== undefined", self._js_callback.emit) @@ -218,15 +462,27 @@ def _on_js_callback(self, ready: bool): return self._is_initialized = True self._startup_timer.stop() - if self._startup_cmd: - self.write(self._startup_cmd) + if self.startup_cmd: + if self._unique_id: + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + return + if not page_info.initialized: + page_info.initialized = True + self.write(self.startup_cmd) + else: + self.write(self.startup_cmd) self.initialized.emit() - @SafeProperty(str) + @property def startup_cmd(self): """ Get the startup command for the web console. """ + if self._is_bec_shell: + if self.bec_dispatcher.cli_server is None: + return "bec --nogui" + return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}" return self._startup_cmd @startup_cmd.setter @@ -241,11 +497,123 @@ def startup_cmd(self, cmd: str): def write(self, data: str, send_return: bool = True): """ Send data to the web page + + Args: + data (str): The data to send. + send_return (bool): Whether to send a return after the data. """ - self.page.runJavaScript(f"window.term.paste('{data}');") + cmd = f"window.term.paste({json.dumps(data)});" + if self.page is None: + logger.warning("Cannot write to web console: page is not initialized.") + return + self.page.runJavaScript(cmd) if send_return: self.send_return() + def take_page_ownership(self, unique_id: str | None = None): + """ + Take ownership of a web page from the registry. This will transfer the page + from its current owner (if any) to this widget. + + Args: + unique_id (str): The unique identifier of the page to take ownership of. + If None, uses this widget's unique_id. + """ + if unique_id is None: + unique_id = self._unique_id + + if not unique_id: + logger.warning("Cannot take page ownership without a unique_id") + return + + # Get the page from registry + page = _web_console_registry.take_page_ownership(unique_id, self.gui_id) + + if not page: + logger.warning(f"Page {unique_id} not found in registry") + return + + self.page = page + self.browser.setPage(page) + self._set_mode(ConsoleMode.ACTIVE) + logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}") + + def _on_ownership_lost(self): + """ + Called when this widget loses ownership of its page. + Displays the overlay and hides the browser. + """ + self._set_mode(ConsoleMode.INACTIVE) + logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}") + + def yield_ownership(self): + """ + Yield ownership of the page. The page remains in the registry with no owner, + available for another widget to claim. This is automatically called when the + widget becomes hidden. + """ + if not self._unique_id: + return + success = _web_console_registry.yield_ownership(self.gui_id) + if success: + self._on_ownership_lost() + logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}") + + def has_ownership(self) -> bool: + """ + Check if this widget currently has ownership of a page. + + Returns: + bool: True if this widget owns a page, False otherwise. + """ + if not self._unique_id: + return False + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + return False + return page_info.owner_gui_id == self.gui_id + + def hideEvent(self, event): + """ + Called when the widget is hidden. Automatically yields ownership. + """ + if self.has_ownership(): + self.yield_ownership() + self._set_mode(ConsoleMode.HIDDEN) + super().hideEvent(event) + + def showEvent(self, event): + """ + Called when the widget is shown. Updates UI state based on ownership. + """ + super().showEvent(event) + if self._unique_id and not self.has_ownership(): + # Take ownership if the page does not have an owner or + # the owner is not visible + page_info = _web_console_registry.get_page_info(self._unique_id) + if page_info is None: + self._set_mode(ConsoleMode.INACTIVE) + return + if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible( + self._unique_id + ): + self.take_page_ownership(self._unique_id) + return + if page_info.owner_gui_id != self.gui_id: + self._set_mode(ConsoleMode.INACTIVE) + return + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self.overlay.resize(event.size()) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership(): + self.take_page_ownership(self._unique_id) + event.accept() + return + return super().mousePressEvent(event) + def _authenticate(self, _, auth): """ Authenticate the request with the provided username and password. @@ -286,10 +654,52 @@ def cleanup(self): super().cleanup() +class BECShell(WebConsole): + """ + A WebConsole pre-configured to run the BEC shell. + We cannot simply expose the web console properties to Qt as we need to have a deterministic + startup behavior for sharing the same shell instance across multiple widgets. + """ + + ICON_NAME = "hub" + + def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + super().__init__( + parent=parent, + config=config, + client=client, + gui_id=gui_id, + is_bec_shell=True, + unique_id="bec_shell", + **kwargs, + ) + + if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - widget = WebConsole() + widget = QTabWidget() + + # Create two consoles with different unique_ids + web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") + web_console2 = WebConsole(startup_cmd="htop") + web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1") + widget.addTab(web_console1, "Console 1") + widget.addTab(web_console2, "Console 2") + widget.addTab(web_console3, "Console 3 -- mirror of Console 1") widget.show() + + # Demonstrate page sharing: + # After initialization, web_console2 can take ownership of console1's page: + # web_console2.take_page_ownership("console1") + + widget.resize(800, 600) + + def _close_cons1(): + web_console2.close() + web_console2.deleteLater() + + # QTimer.singleShot(3000, _close_cons1) + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 0430af7db..c77ac543a 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -2208,7 +2208,7 @@ def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): "flat_status": "BECStatusBox", "flat_progress_bar": "RingProgressBar", "flat_terminal": "WebConsole", - "flat_bec_shell": "WebConsole", + "flat_bec_shell": "BECShell", "flat_sbb_monitor": "SBBMonitor", } diff --git a/tests/unit_tests/test_web_console.py b/tests/unit_tests/test_web_console.py index 3da2f9a0e..be49cff11 100644 --- a/tests/unit_tests/test_web_console.py +++ b/tests/unit_tests/test_web_console.py @@ -1,25 +1,69 @@ from unittest import mock import pytest +from qtpy.QtCore import Qt +from qtpy.QtGui import QHideEvent from qtpy.QtNetwork import QAuthenticator -from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry +from bec_widgets.widgets.editors.web_console.web_console import ( + BECShell, + ConsoleMode, + WebConsole, + _web_console_registry, +) from .client_mocks import mocked_client @pytest.fixture -def console_widget(qtbot, mocked_client): +def mocked_server_startup(): + """Mock the web console server startup process.""" with mock.patch( "bec_widgets.widgets.editors.web_console.web_console.subprocess" ) as mock_subprocess: with mock.patch.object(_web_console_registry, "_wait_for_server_port"): _web_console_registry._server_port = 12345 - # Create the WebConsole widget - widget = WebConsole(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget + yield mock_subprocess + + +def static_console(qtbot, client, unique_id: str | None = None): + """Fixture to provide a static unique_id for WebConsole tests.""" + if unique_id is None: + widget = WebConsole(client=client) + else: + widget = WebConsole(client=client, unique_id=unique_id) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + return widget + + +@pytest.fixture +def console_widget(qtbot, mocked_client, mocked_server_startup): + """Create a WebConsole widget with mocked server startup.""" + yield static_console(qtbot, mocked_client) + + +@pytest.fixture +def bec_shell_widget(qtbot, mocked_client, mocked_server_startup): + """Create a BECShell widget with mocked server startup.""" + widget = BECShell(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup): + """Create a WebConsole widget with a static unique ID.""" + yield static_console(qtbot, mocked_client, unique_id="test_console") + + +@pytest.fixture +def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup): + """Create two WebConsole widgets sharing the same unique ID.""" + widget1 = static_console(qtbot, mocked_client, unique_id="shared_console") + widget2 = static_console(qtbot, mocked_client, unique_id="shared_console") + yield widget1, widget2 def test_web_console_widget_initialization(console_widget): @@ -34,7 +78,7 @@ def test_web_console_write(console_widget): with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!") - assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls + assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls def test_web_console_write_no_return(console_widget): @@ -42,7 +86,7 @@ def test_web_console_write_no_return(console_widget): with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js: console_widget.write("Hello, World!", send_return=False) - assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls + assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls assert mock_run_js.call_count == 1 @@ -138,6 +182,20 @@ def mock_run_js(script, callback=None): assert not console_widget._startup_timer.isActive() +def test_bec_shell_startup_contains_gui_id(bec_shell_widget): + """Test that the BEC shell startup command includes the GUI ID.""" + bec_shell = bec_shell_widget + + assert bec_shell._is_bec_shell + assert bec_shell._unique_id == "bec_shell" + + assert bec_shell.startup_cmd == "bec --nogui" + + with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server: + mock_cli_server.gui_id = "test_gui_id" + assert bec_shell.startup_cmd == "bec --gui-id test_gui_id" + + def test_web_console_set_readonly(console_widget): # Test the set_readonly method console_widget.set_readonly(True) @@ -145,3 +203,274 @@ def test_web_console_set_readonly(console_widget): console_widget.set_readonly(False) assert console_widget.isEnabled() + + +def test_web_console_with_unique_id(console_widget_with_static_id): + """Test creating a WebConsole with a unique_id.""" + widget = console_widget_with_static_id + + assert widget._unique_id == "test_console" + assert widget._unique_id in _web_console_registry._page_registry + page_info = _web_console_registry.get_page_info("test_console") + assert page_info is not None + assert page_info.owner_gui_id == widget.gui_id + assert widget.gui_id in page_info.widget_ids + + +def test_web_console_page_sharing(two_console_widgets_same_id): + """Test that two widgets can share the same page using unique_id.""" + widget1, widget2 = two_console_widgets_same_id + + # Both should reference the same page in the registry + page_info = _web_console_registry.get_page_info("shared_console") + assert page_info is not None + assert widget1.gui_id in page_info.widget_ids + assert widget2.gui_id in page_info.widget_ids + assert widget1.page == widget2.page + + +def test_web_console_has_ownership(console_widget_with_static_id): + """Test the has_ownership method.""" + widget = console_widget_with_static_id + + # Widget should have ownership by default + assert widget.has_ownership() + + +def test_web_console_yield_ownership(console_widget_with_static_id): + """Test yielding ownership of a page.""" + widget = console_widget_with_static_id + + assert widget.has_ownership() + + # Yield ownership + widget.yield_ownership() + + # Widget should no longer have ownership + assert not widget.has_ownership() + page_info = _web_console_registry.get_page_info("test_console") + assert page_info.owner_gui_id is None + # Overlay should be shown + assert widget._mode == ConsoleMode.INACTIVE + + +def test_web_console_take_page_ownership(two_console_widgets_same_id): + """Test taking ownership of a page.""" + widget1, widget2 = two_console_widgets_same_id + + # Widget1 should have ownership initially + assert widget1.has_ownership() + assert not widget2.has_ownership() + + # Widget2 takes ownership + widget2.take_page_ownership() + + # Now widget2 should have ownership + assert not widget1.has_ownership() + assert widget2.has_ownership() + + assert widget2._mode == ConsoleMode.ACTIVE + assert widget1._mode == ConsoleMode.INACTIVE + + +def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id): + """Test that hideEvent yields ownership.""" + widget = console_widget_with_static_id + + assert widget.has_ownership() + + # Hide the widget. Note that we cannot call widget.hide() directly + # because it doesn't trigger the hideEvent in tests as widgets are + # not visible in the test environment. + widget.hideEvent(QHideEvent()) + qtbot.wait(100) # Allow event processing + + # Widget should have yielded ownership + assert not widget.has_ownership() + page_info = _web_console_registry.get_page_info("test_console") + assert page_info.owner_gui_id is None + + +def test_web_console_show_event_takes_ownership(console_widget_with_static_id): + """Test that showEvent takes ownership when page has no owner.""" + widget = console_widget_with_static_id + + # Yield ownership + widget.yield_ownership() + assert not widget.has_ownership() + + # Show the widget again + widget.show() + + # Widget should have reclaimed ownership + assert widget.has_ownership() + assert widget.browser.isVisible() + assert not widget.overlay.isVisible() + + +def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id): + """Test that clicking on overlay takes ownership.""" + widget1, widget2 = two_console_widgets_same_id + widget1.show() + widget2.show() + + # Widget1 has ownership, widget2 doesn't + assert widget1.has_ownership() + assert not widget2.has_ownership() + assert widget1.isVisible() + assert widget1._mode == ConsoleMode.ACTIVE + assert widget2._mode == ConsoleMode.INACTIVE + + qtbot.mouseClick(widget2, Qt.MouseButton.LeftButton) + + # Widget2 should now have ownership + assert widget2.has_ownership() + assert not widget1.has_ownership() + + +def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id): + """Test that the registry cleans up pages when all widgets are removed.""" + widget = console_widget_with_static_id + + assert widget._unique_id in _web_console_registry._page_registry + + # Cleanup the widget + widget.cleanup() + + # Page should be removed from registry + assert widget._unique_id not in _web_console_registry._page_registry + + +def test_web_console_without_unique_id_no_page_sharing(console_widget): + """Test that widgets without unique_id don't participate in page sharing.""" + widget = console_widget + + # Widget should not be in the page registry + assert widget._unique_id is None + assert not widget.has_ownership() # Should return False for non-unique widgets + + +def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client): + """Test getting page info for a non-existent page.""" + page_info = _web_console_registry.get_page_info("nonexistent") + assert page_info is None + + +def test_web_console_take_ownership_without_unique_id(console_widget): + """Test that take_page_ownership fails gracefully without unique_id.""" + widget = console_widget + # Should not crash when taking ownership without unique_id + widget.take_page_ownership() + + +def test_web_console_yield_ownership_without_unique_id(console_widget): + """Test that yield_ownership fails gracefully without unique_id.""" + widget = console_widget + # Should not crash when yielding ownership without unique_id + widget.yield_ownership() + + +def test_registry_yield_ownership_gui_id_not_in_instances(): + """Test registry yield_ownership returns False when gui_id not in instances.""" + result = _web_console_registry.yield_ownership("nonexistent_gui_id") + assert result is False + + +def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id): + """Test registry yield_ownership returns False when instance weakref is dead.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + + # Store the gui_id and simulate the weakref being dead + _web_console_registry._instances[gui_id] = lambda: None + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + +def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id): + """Test registry yield_ownership returns False when page info's unique_id is None.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + unique_id = widget._unique_id + widget._unique_id = None + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + widget._unique_id = unique_id # Restore for cleanup + + +def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_with_static_id): + """Test registry yield_ownership returns False when unique_id not in page registry.""" + widget = console_widget_with_static_id + gui_id = widget.gui_id + unique_id = widget._unique_id + widget._unique_id = "nonexistent_unique_id" + + result = _web_console_registry.yield_ownership(gui_id) + assert result is False + + widget._unique_id = unique_id # Restore for cleanup + + +def test_registry_owner_is_visible_page_info_none(): + """Test owner_is_visible returns False when page info doesn't exist.""" + result = _web_console_registry.owner_is_visible("nonexistent_page") + assert result is False + + +def test_registry_owner_is_visible_no_owner(console_widget_with_static_id): + """Test owner_is_visible returns False when page has no owner.""" + widget = console_widget_with_static_id + + # Yield ownership so there's no owner + widget.yield_ownership() + page_info = _web_console_registry.get_page_info(widget._unique_id) + assert page_info.owner_gui_id is None + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id): + """Test owner_is_visible returns False when owner ref doesn't exist in instances.""" + widget = console_widget_with_static_id + unique_id = widget._unique_id + + # Remove owner from instances dict + del _web_console_registry._instances[widget.gui_id] + + result = _web_console_registry.owner_is_visible(unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_instance_none(console_widget_with_static_id): + """Test owner_is_visible returns False when owner instance weakref is dead.""" + widget = console_widget_with_static_id + unique_id = widget._unique_id + gui_id = widget.gui_id + + # Simulate dead weakref + _web_console_registry._instances[gui_id] = lambda: None + + result = _web_console_registry.owner_is_visible(unique_id) + assert result is False + + +def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id): + """Test owner_is_visible returns True when owner is visible.""" + widget = console_widget_with_static_id + widget.show() + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is True + + +def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_id): + """Test owner_is_visible returns False when owner is not visible.""" + widget = console_widget_with_static_id + widget.hide() + + result = _web_console_registry.owner_is_visible(widget._unique_id) + assert result is False From 5f30ab5aa2c0a4f331205c55d63487ee6aab08a9 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 13 Jan 2026 17:11:40 +0100 Subject: [PATCH 089/155] test(script_tree): improve hover event handling with waitUntil --- tests/unit_tests/test_script_tree_widget.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/test_script_tree_widget.py b/tests/unit_tests/test_script_tree_widget.py index e69ccae7c..964219769 100644 --- a/tests/unit_tests/test_script_tree_widget.py +++ b/tests/unit_tests/test_script_tree_widget.py @@ -55,20 +55,16 @@ def test_script_tree_hover_events(script_tree, qtbot): # Send the event to the viewport (the event filter is installed on the viewport) script_tree.eventFilter(viewport, mouse_event) - qtbot.wait(100) # Allow time for the hover to be processed - # Now, the hover index should be set to the first item - assert script_tree.delegate.hovered_index.isValid() == True + qtbot.waitUntil(lambda: script_tree.delegate.hovered_index.isValid(), timeout=5000) assert script_tree.delegate.hovered_index.row() == index.row() # Simulate mouse leaving the viewport leave_event = QEvent(QEvent.Type.Leave) script_tree.eventFilter(viewport, leave_event) - qtbot.wait(100) # Allow time for the leave event to be processed - # After leaving, no item should be hovered - assert script_tree.delegate.hovered_index.isValid() == False + qtbot.waitUntil(lambda: not script_tree.delegate.hovered_index.isValid(), timeout=5000) @pytest.mark.timeout(10) From 5209f4c2102519c4fe9020d35b6894d4d3cc1931 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 Jan 2026 14:34:00 +0100 Subject: [PATCH 090/155] fix(heatmap): devices are saved as SafeProperties --- bec_widgets/cli/client.py | 84 ++++++ bec_widgets/widgets/plots/heatmap/heatmap.py | 263 +++++++++++++++++- tests/unit_tests/test_heatmap_widget.py | 274 +++++++++++++++++++ 3 files changed, 618 insertions(+), 3 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 7ae6767f0..b140f63cf 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2039,6 +2039,90 @@ def plot( reload (bool): Whether to reload the heatmap with new data. """ + @property + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @x_device_name.setter + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @property + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @x_device_entry.setter + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @property + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @y_device_name.setter + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @property + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @y_device_entry.setter + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @property + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @z_device_name.setter + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @property + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ + + @z_device_entry.setter + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ + class Image(RPCBase): """Image widget for displaying 2D data.""" diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index d715772de..dc80269e9 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -203,6 +203,19 @@ class Heatmap(ImageBase): "remove_roi", "rois", "plot", + # Device properties + "x_device_name", + "x_device_name.setter", + "x_device_entry", + "x_device_entry.setter", + "y_device_name", + "y_device_name.setter", + "y_device_entry", + "y_device_entry.setter", + "z_device_name", + "z_device_name.setter", + "z_device_entry", + "z_device_entry.setter", ] PLUGIN = True @@ -413,9 +426,15 @@ def update_labels(self): """ if self._image_config is None: return - x_name = self._image_config.x_device.name - y_name = self._image_config.y_device.name - z_name = self._image_config.z_device.name + + # Safely get device names (might be None if not yet configured) + x_device = self._image_config.x_device + y_device = self._image_config.y_device + z_device = self._image_config.z_device + + x_name = x_device.name if x_device else None + y_name = y_device.name if y_device else None + z_name = z_device.name if z_device else None if x_name is not None: self.x_label = x_name # type: ignore @@ -1136,6 +1155,244 @@ def reset(self): self.crosshair.reset() super().reset() + ################################################################################ + # Widget Specific Properties + ################################################################################ + + @SafeProperty(str) + def x_device_name(self) -> str: + """Device name for the X axis.""" + if self._image_config.x_device is None: + return "" + return self._image_config.x_device.name or "" + + @x_device_name.setter + def x_device_name(self, device_name: str) -> None: + """ + Set the X device name. + + Args: + device_name(str): Device name for the X axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.x_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("x_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.x_device = None + self.property_changed.emit("x_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def x_device_entry(self) -> str: + """Signal entry for the X axis device.""" + if self._image_config.x_device is None: + return "" + return self._image_config.x_device.entry or "" + + @x_device_entry.setter + def x_device_entry(self, entry: str) -> None: + """ + Set the X device entry. + + Args: + entry(str): Signal entry for the X axis device + """ + if not entry: + return + + if self._image_config.x_device is None: + logger.warning("Cannot set x_device_entry without x_device_name set first.") + return + + device_name = self._image_config.x_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.x_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("x_device_entry", validated_entry) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def y_device_name(self) -> str: + """Device name for the Y axis.""" + if self._image_config.y_device is None: + return "" + return self._image_config.y_device.name or "" + + @y_device_name.setter + def y_device_name(self, device_name: str) -> None: + """ + Set the Y device name. + + Args: + device_name(str): Device name for the Y axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.y_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("y_device_name", device_name) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + self._image_config.y_device = None + self.property_changed.emit("y_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def y_device_entry(self) -> str: + """Signal entry for the Y axis device.""" + if self._image_config.y_device is None: + return "" + return self._image_config.y_device.entry or "" + + @y_device_entry.setter + def y_device_entry(self, entry: str) -> None: + """ + Set the Y device entry. + + Args: + entry(str): Signal entry for the Y axis device + """ + if not entry: + return + + if self._image_config.y_device is None: + logger.warning("Cannot set y_device_entry without y_device_name set first.") + return + + device_name = self._image_config.y_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.y_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("y_device_entry", validated_entry) + self.update_labels() # Update axis labels + self._try_auto_plot() + except Exception as e: + logger.debug(f"Y device entry validation failed: {e}") + pass # Silently fail if validation fails + + @SafeProperty(str) + def z_device_name(self) -> str: + """Device name for the Z (color) axis.""" + if self._image_config.z_device is None: + return "" + return self._image_config.z_device.name or "" + + @z_device_name.setter + def z_device_name(self, device_name: str) -> None: + """ + Set the Z device name. + + Args: + device_name(str): Device name for the Z axis + """ + device_name = device_name or "" + + # Get current entry or validate + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + self._image_config.z_device = HeatmapDeviceSignal(name=device_name, entry=entry) + self.property_changed.emit("z_device_name", device_name) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device name validation failed: {e}") + pass # Silently fail if device is not available yet + else: + self._image_config.z_device = None + self.property_changed.emit("z_device_name", "") + self.update_labels() # Clear axis labels + + @SafeProperty(str) + def z_device_entry(self) -> str: + """Signal entry for the Z (color) axis device.""" + if self._image_config.z_device is None: + return "" + return self._image_config.z_device.entry or "" + + @z_device_entry.setter + def z_device_entry(self, entry: str) -> None: + """ + Set the Z device entry. + + Args: + entry(str): Signal entry for the Z axis device + """ + if not entry: + return + + if self._image_config.z_device is None: + logger.warning("Cannot set z_device_entry without z_device_name set first.") + return + + device_name = self._image_config.z_device.name + try: + # Validate the entry for this device + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._image_config.z_device = HeatmapDeviceSignal( + name=device_name, entry=validated_entry + ) + self.property_changed.emit("z_device_entry", validated_entry) + self.update_labels() # Update axis labels (title) + self._try_auto_plot() + except Exception as e: + logger.debug(f"Z device entry validation failed: {e}") + pass # Silently fail if validation fails + + def _try_auto_plot(self) -> None: + """ + Attempt to automatically call plot() if all three devices are set. + Similar to waveform's approach but requires all three devices. + """ + has_x = self._image_config.x_device is not None + has_y = self._image_config.y_device is not None + has_z = self._image_config.z_device is not None + + if has_x and has_y and has_z: + x_name = self._image_config.x_device.name + x_entry = self._image_config.x_device.entry + y_name = self._image_config.y_device.name + y_entry = self._image_config.y_device.entry + z_name = self._image_config.z_device.name + z_entry = self._image_config.z_device.entry + try: + self.plot( + x_name=x_name, + y_name=y_name, + z_name=z_name, + x_entry=x_entry, + y_entry=y_entry, + z_entry=z_entry, + validate_bec=False, # Don't validate - entries already validated + ) + except Exception as e: + logger.debug(f"Auto-plot failed: {e}") + pass # Silently fail if plot cannot be called yet + @SafeProperty(str) def interpolation_method(self) -> str: """ diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 517e9f052..38a7b67a0 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -597,3 +597,277 @@ def test_finish_interpolation_thread_cleans_references(heatmap_widget): thread_mock.deleteLater.assert_called_once() assert heatmap_widget._interpolation_worker is None assert heatmap_widget._interpolation_thread is None + + +def test_device_safe_properties_get(heatmap_widget): + """Test that device SafeProperty getters work correctly.""" + # Initially devices should be empty + assert heatmap_widget.x_device_name == "" + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.y_device_entry == "" + assert heatmap_widget.z_device_name == "" + assert heatmap_widget.z_device_entry == "" + + # Set devices via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Check properties return device names and entries separately + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.x_device_entry # Should have some entry + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.y_device_entry # Should have some entry + assert heatmap_widget.z_device_name == "bpm4i" + assert heatmap_widget.z_device_entry # Should have some entry + + +def test_device_safe_properties_set_name(heatmap_widget): + """Test that device SafeProperty setters work for device names.""" + # Set x_device_name - should auto-validate entry + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.x_device.name == "samx" + assert heatmap_widget._image_config.x_device.entry is not None # Entry should be validated + assert heatmap_widget.x_device_name == "samx" + + # Set y_device_name + heatmap_widget.y_device_name = "samy" + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.y_device.name == "samy" + assert heatmap_widget._image_config.y_device.entry is not None + assert heatmap_widget.y_device_name == "samy" + + # Set z_device_name + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget._image_config.z_device is not None + assert heatmap_widget._image_config.z_device.name == "bpm4i" + assert heatmap_widget._image_config.z_device.entry is not None + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_safe_properties_set_entry(heatmap_widget): + """Test that device entry properties can override default entries.""" + # Set device name first - this auto-validates entry + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.x_device_entry + assert initial_entry # Should have auto-validated entry + + # Override with specific entry + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget._image_config.x_device.entry == "samx" + assert heatmap_widget.x_device_entry == "samx" + + # Same for y device + heatmap_widget.y_device_name = "samy" + heatmap_widget.y_device_entry = "samy_setpoint" + assert heatmap_widget._image_config.y_device.entry == "samy_setpoint" + + # Same for z device + heatmap_widget.z_device_name = "bpm4i" + heatmap_widget.z_device_entry = "bpm4i" + assert heatmap_widget._image_config.z_device.entry == "bpm4i" + + +def test_device_entry_cannot_be_set_without_name(heatmap_widget): + """Test that setting entry without device name logs warning and does nothing.""" + # Try to set entry without device name + heatmap_widget.x_device_entry = "some_entry" + # Should not crash, entry should remain empty + assert heatmap_widget.x_device_entry == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_set_empty(heatmap_widget): + """Test that device SafeProperty setters handle empty strings.""" + # Set device first + heatmap_widget.x_device_name = "samx" + assert heatmap_widget._image_config.x_device is not None + + # Set to empty string - should clear the device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + +def test_device_safe_properties_auto_plot(heatmap_widget): + """Test that setting all three devices triggers auto-plot.""" + # Set all three devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Check that plot was called (image_config should be updated) + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + assert heatmap_widget._image_config.z_device is not None + + +def test_device_properties_update_labels(heatmap_widget): + """Test that setting device properties updates axis labels.""" + # Set x device - should update x label + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_label == "samx" + + # Set y device - should update y label + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.y_label == "samy" + + # Set z device - should update title + heatmap_widget.z_device_name = "bpm4i" + assert heatmap_widget.title == "bpm4i" + + +def test_device_properties_partial_configuration(heatmap_widget): + """Test that widget handles partial device configuration gracefully.""" + # Set only x device + heatmap_widget.x_device_name = "samx" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "" + assert heatmap_widget.z_device_name == "" + + # Set only y device (x already set) + heatmap_widget.y_device_name = "samy" + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "" + + # Auto-plot should not trigger yet (z missing) + # But devices should be configured + assert heatmap_widget._image_config.x_device is not None + assert heatmap_widget._image_config.y_device is not None + + +def test_device_properties_in_user_access(heatmap_widget): + """Test that device properties are exposed in USER_ACCESS for RPC.""" + from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap + + assert "x_device_name" in Heatmap.USER_ACCESS + assert "x_device_name.setter" in Heatmap.USER_ACCESS + assert "x_device_entry" in Heatmap.USER_ACCESS + assert "x_device_entry.setter" in Heatmap.USER_ACCESS + assert "y_device_name" in Heatmap.USER_ACCESS + assert "y_device_name.setter" in Heatmap.USER_ACCESS + assert "y_device_entry" in Heatmap.USER_ACCESS + assert "y_device_entry.setter" in Heatmap.USER_ACCESS + assert "z_device_name" in Heatmap.USER_ACCESS + assert "z_device_name.setter" in Heatmap.USER_ACCESS + assert "z_device_entry" in Heatmap.USER_ACCESS + assert "z_device_entry.setter" in Heatmap.USER_ACCESS + + +def test_device_properties_validation(heatmap_widget): + """Test that device entries are validated through entry_validator.""" + # Set device name - entry should be auto-validated + heatmap_widget.x_device_name = "samx" + initial_entry = heatmap_widget.x_device_entry + + # The entry should be validated (will be "samx" in the mock) + assert initial_entry == "samx" + + # Set a different entry - should also be validated + heatmap_widget.x_device_entry = "samx" # Use same name as validated entry + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_plot_method(heatmap_widget): + """Test that device properties reflect values set via plot() method.""" + # Use plot method + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Properties should reflect the plotted devices + assert heatmap_widget.x_device_name == "samx" + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + # Entries should be validated + assert heatmap_widget.x_device_entry == "samx" + assert heatmap_widget.y_device_entry == "samy" + assert heatmap_widget.z_device_entry == "bpm4i" + + +def test_device_properties_overwrite_via_properties(heatmap_widget): + """Test that device properties can overwrite values set via plot().""" + # First set via plot + heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Overwrite x device via properties + heatmap_widget.x_device_name = "samz" + assert heatmap_widget.x_device_name == "samz" + assert heatmap_widget._image_config.x_device.name == "samz" + + # Overwrite y device entry + heatmap_widget.y_device_entry = "samy" + assert heatmap_widget.y_device_entry == "samy" + + +def test_device_properties_clearing_devices(heatmap_widget): + """Test clearing devices by setting to empty string.""" + # Set all devices + heatmap_widget.x_device_name = "samx" + heatmap_widget.y_device_name = "samy" + heatmap_widget.z_device_name = "bpm4i" + + # Clear x device + heatmap_widget.x_device_name = "" + assert heatmap_widget.x_device_name == "" + assert heatmap_widget._image_config.x_device is None + + # Y and Z should still be set + assert heatmap_widget.y_device_name == "samy" + assert heatmap_widget.z_device_name == "bpm4i" + + +def test_device_properties_property_changed_signal(heatmap_widget): + """Test that property_changed signal is emitted when devices are set.""" + from unittest.mock import Mock + + # Connect mock to property_changed signal + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + # Set device name + heatmap_widget.x_device_name = "samx" + + # Signal should have been emitted + assert mock_handler.called + # Check it was called with correct arguments + mock_handler.assert_any_call("x_device_name", "samx") + + +def test_device_entry_validation_with_invalid_device(heatmap_widget): + """Test that invalid device names are handled gracefully.""" + # Try to set invalid device name + heatmap_widget.x_device_name = "nonexistent_device" + + # Should not crash, but device might not be set if validation fails + # The implementation silently fails, so we just check it doesn't crash + + +def test_device_properties_sequential_entry_changes(heatmap_widget): + """Test changing device entry multiple times.""" + # Set device + heatmap_widget.x_device_name = "samx" + + # Change entry multiple times + heatmap_widget.x_device_entry = "samx_velocity" + assert heatmap_widget.x_device_entry == "samx_velocity" + + heatmap_widget.x_device_entry = "samx_setpoint" + assert heatmap_widget.x_device_entry == "samx_setpoint" + + heatmap_widget.x_device_entry = "samx" + assert heatmap_widget.x_device_entry == "samx" + + +def test_device_properties_with_none_values(heatmap_widget): + """Test that None values are handled as empty strings.""" + # Device name None should be treated as empty + heatmap_widget.x_device_name = None + assert heatmap_widget.x_device_name == "" + + # Set a device first + heatmap_widget.y_device_name = "samy" + + # Entry None should not change anything + heatmap_widget.y_device_entry = None + assert heatmap_widget.y_device_entry # Should still have validated entry From 7e9cc20e59dbd9b0e7abc818335b71341a802b5f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 Jan 2026 16:37:21 +0100 Subject: [PATCH 091/155] fix(scatter_waveform): devices and entries saved as properties --- bec_widgets/cli/client.py | 84 +++++ .../scatter_waveform/scatter_waveform.py | 298 ++++++++++++++++- tests/unit_tests/test_scatter_waveform.py | 309 ++++++++++++++++++ 3 files changed, 687 insertions(+), 4 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index b140f63cf..7f82ef2ef 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -5441,6 +5441,90 @@ def clear_all(self): Clear all the curves from the plot. """ + @property + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @x_device_name.setter + @rpc_call + def x_device_name(self) -> "str": + """ + Device name for the X axis. + """ + + @property + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @x_device_entry.setter + @rpc_call + def x_device_entry(self) -> "str": + """ + Signal entry for the X axis device. + """ + + @property + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @y_device_name.setter + @rpc_call + def y_device_name(self) -> "str": + """ + Device name for the Y axis. + """ + + @property + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @y_device_entry.setter + @rpc_call + def y_device_entry(self) -> "str": + """ + Signal entry for the Y axis device. + """ + + @property + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @z_device_name.setter + @rpc_call + def z_device_name(self) -> "str": + """ + Device name for the Z (color) axis. + """ + + @property + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ + + @z_device_entry.setter + @rpc_call + def z_device_entry(self) -> "str": + """ + Signal entry for the Z (color) axis device. + """ + class SignalComboBox(RPCBase): """Line edit widget for device input with autocomplete for device names.""" diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 28ae9d7f5..39736535d 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -51,6 +51,19 @@ class ScatterWaveform(PlotBase): "plot", "update_with_scan_history", "clear_all", + # Device properties + "x_device_name", + "x_device_name.setter", + "x_device_entry", + "x_device_entry.setter", + "y_device_name", + "y_device_name.setter", + "y_device_entry", + "y_device_entry.setter", + "z_device_name", + "z_device_name.setter", + "z_device_entry", + "z_device_entry.setter", ] sync_signal_update = Signal() @@ -285,10 +298,6 @@ def _add_main_scatter_curve(self, config: ScatterCurveConfig): Args: config(ScatterCurveConfig): The configuration of the scatter curve. """ - # Apply suffix for axes - self.set_x_label_suffix(f"[{config.x_device.name}-{config.x_device.name}]") - self.set_y_label_suffix(f"[{config.y_device.name}-{config.y_device.name}]") - # To have only one main curve if self._main_curve is not None: self.rpc_register.remove_rpc(self._main_curve) @@ -298,6 +307,9 @@ def _add_main_scatter_curve(self, config: ScatterCurveConfig): self._main_curve = None self._main_curve = ScatterCurve(parent_item=self, config=config, name=config.label) + + # Update axis labels (matching Heatmap's label policy) + self.update_labels() self.plot_item.addItem(self._main_curve) self.sync_signal_update.emit() @@ -405,6 +417,284 @@ def _fetch_scan_data_and_access(self): scan_devices = self.scan_item.devices return scan_devices, "value" + ################################################################################ + # Widget Specific Properties + ################################################################################ + + @SafeProperty(str) + def x_device_name(self) -> str: + """Device name for the X axis.""" + if self._main_curve is None or self._main_curve.config.x_device is None: + return "" + return self._main_curve.config.x_device.name or "" + + @x_device_name.setter + def x_device_name(self, device_name: str) -> None: + """ + Set the X device name. + + Args: + device_name(str): Device name for the X axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.x_device is None: + self._main_curve.config.x_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.x_device.name = device_name + self._main_curve.config.x_device.entry = entry + self.property_changed.emit("x_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.x_device is not None: + self._main_curve.config.x_device = None + self.property_changed.emit("x_device_name", "") + self.update_labels() + + @SafeProperty(str) + def x_device_entry(self) -> str: + """Signal entry for the X axis device.""" + if self._main_curve is None or self._main_curve.config.x_device is None: + return "" + return self._main_curve.config.x_device.entry or "" + + @x_device_entry.setter + def x_device_entry(self, entry: str) -> None: + """ + Set the X device entry. + + Args: + entry(str): Signal entry for the X axis device + """ + if not entry: + return + + if self._main_curve.config.x_device is None: + logger.warning("Cannot set x_device_entry without x_device_name set first.") + return + + device_name = self._main_curve.config.x_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.x_device.entry = validated_entry + self.property_changed.emit("x_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def y_device_name(self) -> str: + """Device name for the Y axis.""" + if self._main_curve is None or self._main_curve.config.y_device is None: + return "" + return self._main_curve.config.y_device.name or "" + + @y_device_name.setter + def y_device_name(self, device_name: str) -> None: + """ + Set the Y device name. + + Args: + device_name(str): Device name for the Y axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.y_device is None: + self._main_curve.config.y_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.y_device.name = device_name + self._main_curve.config.y_device.entry = entry + self.property_changed.emit("y_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.y_device is not None: + self._main_curve.config.y_device = None + self.property_changed.emit("y_device_name", "") + self.update_labels() + + @SafeProperty(str) + def y_device_entry(self) -> str: + """Signal entry for the Y axis device.""" + if self._main_curve is None or self._main_curve.config.y_device is None: + return "" + return self._main_curve.config.y_device.entry or "" + + @y_device_entry.setter + def y_device_entry(self, entry: str) -> None: + """ + Set the Y device entry. + + Args: + entry(str): Signal entry for the Y axis device + """ + if not entry: + return + + if self._main_curve.config.y_device is None: + logger.warning("Cannot set y_device_entry without y_device_name set first.") + return + + device_name = self._main_curve.config.y_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.y_device.entry = validated_entry + self.property_changed.emit("y_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + @SafeProperty(str) + def z_device_name(self) -> str: + """Device name for the Z (color) axis.""" + if self._main_curve is None or self._main_curve.config.z_device is None: + return "" + return self._main_curve.config.z_device.name or "" + + @z_device_name.setter + def z_device_name(self, device_name: str) -> None: + """ + Set the Z device name. + + Args: + device_name(str): Device name for the Z axis + """ + device_name = device_name or "" + + if device_name: + try: + entry = self.entry_validator.validate_signal(device_name, None) + # Update or create config + if self._main_curve.config.z_device is None: + self._main_curve.config.z_device = ScatterDeviceSignal( + name=device_name, entry=entry + ) + else: + self._main_curve.config.z_device.name = device_name + self._main_curve.config.z_device.entry = entry + self.property_changed.emit("z_device_name", device_name) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if device is not available yet + else: + if self._main_curve.config.z_device is not None: + self._main_curve.config.z_device = None + self.property_changed.emit("z_device_name", "") + self.update_labels() + + @SafeProperty(str) + def z_device_entry(self) -> str: + """Signal entry for the Z (color) axis device.""" + if self._main_curve is None or self._main_curve.config.z_device is None: + return "" + return self._main_curve.config.z_device.entry or "" + + @z_device_entry.setter + def z_device_entry(self, entry: str) -> None: + """ + Set the Z device entry. + + Args: + entry(str): Signal entry for the Z axis device + """ + if not entry: + return + + if self._main_curve.config.z_device is None: + logger.warning("Cannot set z_device_entry without z_device_name set first.") + return + + device_name = self._main_curve.config.z_device.name + try: + validated_entry = self.entry_validator.validate_signal(device_name, entry) + self._main_curve.config.z_device.entry = validated_entry + self.property_changed.emit("z_device_entry", validated_entry) + self.update_labels() + self._try_auto_plot() + except Exception: + pass # Silently fail if validation fails + + def _try_auto_plot(self) -> None: + """ + Attempt to automatically call plot() if all three devices are set. + """ + has_x = self._main_curve.config.x_device is not None + has_y = self._main_curve.config.y_device is not None + has_z = self._main_curve.config.z_device is not None + + if has_x and has_y and has_z: + x_name = self._main_curve.config.x_device.name + x_entry = self._main_curve.config.x_device.entry + y_name = self._main_curve.config.y_device.name + y_entry = self._main_curve.config.y_device.entry + z_name = self._main_curve.config.z_device.name + z_entry = self._main_curve.config.z_device.entry + try: + self.plot( + x_name=x_name, + y_name=y_name, + z_name=z_name, + x_entry=x_entry, + y_entry=y_entry, + z_entry=z_entry, + validate_bec=False, # Don't validate - entries already validated + ) + except Exception as e: + logger.debug(f"Auto-plot failed: {e}") + pass # Silently fail if plot cannot be called yet + + def update_labels(self): + """ + Update the labels of the x and y axes based on current device configuration. + """ + if self._main_curve is None: + return + + config = self._main_curve.config + + # Safely get device names + x_device = config.x_device + y_device = config.y_device + + x_name = x_device.name if x_device else None + y_name = y_device.name if y_device else None + + if x_name is not None: + self.x_label = x_name # type: ignore + x_dev = self.dev.get(x_name) + if x_dev and hasattr(x_dev, "egu"): + self.x_label_units = x_dev.egu() + + if y_name is not None: + self.y_label = y_name # type: ignore + y_dev = self.dev.get(y_name) + if y_dev and hasattr(y_dev, "egu"): + self.y_label_units = y_dev.egu() + + ################################################################################ + # Scan History + ################################################################################ + @SafeSlot(int) @SafeSlot(str) @SafeSlot() diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 8476ebe5f..95d950a6c 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -151,3 +151,312 @@ def test_scatter_waveform_scan_progress(qtbot, mocked_client, monkeypatch): # swf.scatter_dialog.close() # assert swf.scatter_dialog is None # assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog" + + +################################################################################ +# Device Property Tests +################################################################################ + + +def test_device_safe_properties_get(qtbot, mocked_client): + """Test that device SafeProperty getters work correctly.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Initially devices should be empty + assert swf.x_device_name == "" + assert swf.x_device_entry == "" + assert swf.y_device_name == "" + assert swf.y_device_entry == "" + assert swf.z_device_name == "" + assert swf.z_device_entry == "" + + # Set devices via plot + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Check properties return device names and entries separately + assert swf.x_device_name == "samx" + assert swf.x_device_entry # Should have some entry + assert swf.y_device_name == "samy" + assert swf.y_device_entry # Should have some entry + assert swf.z_device_name == "bpm4i" + assert swf.z_device_entry # Should have some entry + + +def test_device_safe_properties_set_name(qtbot, mocked_client): + """Test that device SafeProperty setters work for device names.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set x_device_name - should auto-validate entry + swf.x_device_name = "samx" + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.x_device.name == "samx" + assert swf._main_curve.config.x_device.entry is not None # Entry should be validated + assert swf.x_device_name == "samx" + + # Set y_device_name + swf.y_device_name = "samy" + assert swf._main_curve.config.y_device is not None + assert swf._main_curve.config.y_device.name == "samy" + assert swf._main_curve.config.y_device.entry is not None + assert swf.y_device_name == "samy" + + # Set z_device_name + swf.z_device_name = "bpm4i" + assert swf._main_curve.config.z_device is not None + assert swf._main_curve.config.z_device.name == "bpm4i" + assert swf._main_curve.config.z_device.entry is not None + assert swf.z_device_name == "bpm4i" + + +def test_device_safe_properties_set_entry(qtbot, mocked_client): + """Test that device entry properties can override default entries.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device name first - this auto-validates entry + swf.x_device_name = "samx" + initial_entry = swf.x_device_entry + assert initial_entry # Should have auto-validated entry + + # Override with specific entry + swf.x_device_entry = "samx" + assert swf._main_curve.config.x_device.entry == "samx" + assert swf.x_device_entry == "samx" + + # Same for y device + swf.y_device_name = "samy" + swf.y_device_entry = "samy_setpoint" + assert swf._main_curve.config.y_device.entry == "samy_setpoint" + + # Same for z device + swf.z_device_name = "bpm4i" + swf.z_device_entry = "bpm4i" + assert swf._main_curve.config.z_device.entry == "bpm4i" + + +def test_device_entry_cannot_be_set_without_name(qtbot, mocked_client): + """Test that setting entry without device name logs warning and does nothing.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Try to set entry without device name + swf.x_device_entry = "some_entry" + # Should not crash, entry should remain empty + assert swf.x_device_entry == "" + assert swf._main_curve.config.x_device is None + + +def test_device_safe_properties_set_empty(qtbot, mocked_client): + """Test that device SafeProperty setters handle empty strings.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device first + swf.x_device_name = "samx" + assert swf._main_curve.config.x_device is not None + + # Set to empty string - should clear the device + swf.x_device_name = "" + assert swf.x_device_name == "" + assert swf._main_curve.config.x_device is None + + +def test_device_safe_properties_auto_plot(qtbot, mocked_client): + """Test that setting all three devices triggers auto-plot.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set all three devices + swf.x_device_name = "samx" + swf.y_device_name = "samy" + swf.z_device_name = "bpm4i" + + # Check that plot was called (config should be updated) + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.y_device is not None + assert swf._main_curve.config.z_device is not None + + +def test_device_properties_update_labels(qtbot, mocked_client): + """Test that setting device properties updates axis labels.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set x device - should update x label + swf.x_device_name = "samx" + assert swf.x_label == "samx" + + # Set y device - should update y label + swf.y_device_name = "samy" + assert swf.y_label == "samy" + + # Note: ScatterWaveform doesn't have a title like Heatmap does for z_device + + +def test_device_properties_partial_configuration(qtbot, mocked_client): + """Test that widget handles partial device configuration gracefully.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set only x device + swf.x_device_name = "samx" + assert swf.x_device_name == "samx" + assert swf.y_device_name == "" + assert swf.z_device_name == "" + + # Set only y device (x already set) + swf.y_device_name = "samy" + assert swf.x_device_name == "samx" + assert swf.y_device_name == "samy" + assert swf.z_device_name == "" + + # Auto-plot should not trigger yet (z missing) + # But devices should be configured + assert swf._main_curve.config.x_device is not None + assert swf._main_curve.config.y_device is not None + + +def test_device_properties_in_user_access(qtbot, mocked_client): + """Test that device properties are exposed in USER_ACCESS for RPC.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + assert "x_device_name" in ScatterWaveform.USER_ACCESS + assert "x_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "x_device_entry" in ScatterWaveform.USER_ACCESS + assert "x_device_entry.setter" in ScatterWaveform.USER_ACCESS + assert "y_device_name" in ScatterWaveform.USER_ACCESS + assert "y_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "y_device_entry" in ScatterWaveform.USER_ACCESS + assert "y_device_entry.setter" in ScatterWaveform.USER_ACCESS + assert "z_device_name" in ScatterWaveform.USER_ACCESS + assert "z_device_name.setter" in ScatterWaveform.USER_ACCESS + assert "z_device_entry" in ScatterWaveform.USER_ACCESS + assert "z_device_entry.setter" in ScatterWaveform.USER_ACCESS + + +def test_device_properties_validation(qtbot, mocked_client): + """Test that device entries are validated through entry_validator.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device name - entry should be auto-validated + swf.x_device_name = "samx" + initial_entry = swf.x_device_entry + + # The entry should be validated (will be "samx" in the mock) + assert initial_entry == "samx" + + # Set a different entry - should also be validated + swf.x_device_entry = "samx" # Use same name as validated entry + assert swf.x_device_entry == "samx" + + +def test_device_properties_with_plot_method(qtbot, mocked_client): + """Test that device properties reflect values set via plot() method.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Use plot method + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Properties should reflect the plotted devices + assert swf.x_device_name == "samx" + assert swf.y_device_name == "samy" + assert swf.z_device_name == "bpm4i" + + # Entries should be validated + assert swf.x_device_entry == "samx" + assert swf.y_device_entry == "samy" + assert swf.z_device_entry == "bpm4i" + + +def test_device_properties_overwrite_via_properties(qtbot, mocked_client): + """Test that device properties can overwrite values set via plot().""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # First set via plot + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Overwrite x device via properties + swf.x_device_name = "samz" + assert swf.x_device_name == "samz" + assert swf._main_curve.config.x_device.name == "samz" + + # Overwrite y device entry + swf.y_device_entry = "samy" + assert swf.y_device_entry == "samy" + + +def test_device_properties_clearing_devices(qtbot, mocked_client): + """Test clearing devices by setting to empty string.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set all devices + swf.x_device_name = "samx" + swf.y_device_name = "samy" + swf.z_device_name = "bpm4i" + + # Clear x device + swf.x_device_name = "" + assert swf.x_device_name == "" + assert swf._main_curve.config.x_device is None + + # Y and Z should still be set + assert swf.y_device_name == "samy" + assert swf.z_device_name == "bpm4i" + + +def test_device_properties_property_changed_signal(qtbot, mocked_client): + """Test that property_changed signal is emitted when devices are set.""" + from unittest.mock import Mock + + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Connect mock to property_changed signal + mock_handler = Mock() + swf.property_changed.connect(mock_handler) + + # Set device name + swf.x_device_name = "samx" + + # Signal should have been emitted + assert mock_handler.called + # Check it was called with correct arguments + mock_handler.assert_any_call("x_device_name", "samx") + + +def test_device_entry_validation_with_invalid_device(qtbot, mocked_client): + """Test that invalid device names are handled gracefully.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Try to set invalid device name + swf.x_device_name = "nonexistent_device" + + # Should not crash, but device might not be set if validation fails + # The implementation silently fails, so we just check it doesn't crash + + +def test_device_properties_sequential_entry_changes(qtbot, mocked_client): + """Test changing device entry multiple times.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Set device + swf.x_device_name = "samx" + + # Change entry multiple times + swf.x_device_entry = "samx_velocity" + assert swf.x_device_entry == "samx_velocity" + + swf.x_device_entry = "samx_setpoint" + assert swf.x_device_entry == "samx_setpoint" + + swf.x_device_entry = "samx" + assert swf.x_device_entry == "samx" + + +def test_device_properties_with_none_values(qtbot, mocked_client): + """Test that None values are handled as empty strings.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Device name None should be treated as empty + swf.x_device_name = None + assert swf.x_device_name == "" + + # Set a device first + swf.y_device_name = "samy" + + # Entry None should not change anything + swf.y_device_entry = None + assert swf.y_device_entry # Should still have validated entry From 12746ae4aa2d60ef2f0dc36e49d25ba9f6dd8be9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 17 Jan 2026 19:05:32 +0100 Subject: [PATCH 092/155] fix(scatter_waveform): modernization of scatter waveform settings dialog --- .../scatter_waveform/scatter_waveform.py | 17 +- .../settings/scatter_curve_setting.py | 6 +- .../scatter_curve_settings_horizontal.ui | 173 +++++++++++++----- 3 files changed, 141 insertions(+), 55 deletions(-) diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 39736535d..05278c182 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -106,6 +106,13 @@ def __init__( ) self._init_scatter_curve_settings() + + # Show toolbar bundles - only include scatter_waveform_settings if not in SIDE mode + shown_bundles = ["plot_export", "mouse_interaction", "roi", "axis_popup"] + if self.ui_mode != UIMode.SIDE: + shown_bundles.insert(0, "scatter_waveform_settings") + self.toolbar.show_bundles(shown_bundles) + self.update_with_scan_history(-1) ################################################################################ @@ -134,15 +141,9 @@ def _init_scatter_curve_settings(self): checkable=True, parent=self, ) - self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action) - self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings") + self.toolbar.add_action("scatter_waveform_settings", scatter_curve_action) scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings) - shown_bundles = self.toolbar.shown_bundles - if "performance" in shown_bundles: - shown_bundles.remove("performance") - self.toolbar.show_bundles(shown_bundles) - def show_scatter_curve_settings(self): """ Show the scatter curve settings dialog. @@ -158,7 +159,7 @@ def show_scatter_curve_settings(self): window_title="Scatter Curve Settings", modal=False, ) - self.scatter_dialog.resize(620, 200) + self.scatter_dialog.resize(700, 240) # When the dialog is closed, update the toolbar icon and clear the reference self.scatter_dialog.finished.connect(self._scatter_dialog_closed) self.scatter_dialog.show() diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py index 703266af0..c0aa6c0db 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py @@ -86,17 +86,17 @@ def fetch_all_properties(self): if hasattr(self.ui, "x_name"): self.ui.x_name.set_device(x_name) if hasattr(self.ui, "x_entry") and x_entry is not None: - self.ui.x_entry.setText(x_entry) + self.ui.x_entry.set_to_obj_name(x_entry) if hasattr(self.ui, "y_name"): self.ui.y_name.set_device(y_name) if hasattr(self.ui, "y_entry") and y_entry is not None: - self.ui.y_entry.setText(y_entry) + self.ui.y_entry.set_to_obj_name(y_entry) if hasattr(self.ui, "z_name"): self.ui.z_name.set_device(z_name) if hasattr(self.ui, "z_entry") and z_entry is not None: - self.ui.z_entry.setText(z_entry) + self.ui.z_entry.set_to_obj_name(z_entry) @SafeSlot() def accept_changes(self): diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui index a61d20241..945c11d49 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui @@ -6,8 +6,8 @@ 0 0 - 604 - 166 + 826 + 204 @@ -31,6 +31,13 @@ + + + + Qt::Orientation::Horizontal + + + @@ -46,9 +53,6 @@ - - - @@ -56,8 +60,22 @@ + + + + true + + + true + + + - + + + true + + @@ -75,9 +93,6 @@ - - - @@ -85,8 +100,22 @@ + + + + true + + + true + + + - + + + true + + @@ -111,11 +140,22 @@ - - - - + + + true + + + true + + + + + + + true + + @@ -126,76 +166,121 @@ - DeviceLineEdit - QLineEdit -
device_line_edit
+ DeviceComboBox + +
device_combo_box
+
+ + SignalComboBox + +
signal_combo_box
ToggleSwitch - QWidget +
toggle_switch
BECColorMapWidget - QWidget +
bec_color_map_widget
- - x_name - x_entry - y_name - y_entry - z_name - z_entry - x_name - textChanged(QString) + device_reset() x_entry - clear() + reset_selection() - 134 - 95 + 136 + 122 - 138 - 128 + 133 + 151 y_name - textChanged(QString) + device_reset() y_entry - clear() + reset_selection() - 351 - 91 + 412 + 122 - 349 + 409 + 151 + + + + + z_name + device_reset() + z_entry + reset_selection() + + + 687 121 + + 684 + 149 + + + + + x_name + currentTextChanged(QString) + x_entry + set_device(QString) + + + 152 + 123 + + + 151 + 151 + + + + + y_name + currentTextChanged(QString) + y_entry + set_device(QString) + + + 412 + 121 + + + 409 + 149 + z_name - textChanged(QString) + currentTextChanged(QString) z_entry - clear() + set_device(QString) - 520 - 98 + 687 + 121 - 522 - 127 + 684 + 149 From dc3129357b1c147a63aafe94f56df6f0d30cbf1a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 18 Jan 2026 14:29:51 +0100 Subject: [PATCH 093/155] fix(signal_combo_box): get_signal_name added; remove duplicates from heatmap and scatter waveform settings; --- bec_widgets/cli/client.py | 9 ++ .../signal_combobox/signal_combobox.py | 20 +++- .../plots/heatmap/settings/heatmap_setting.py | 31 +---- .../settings/scatter_curve_setting.py | 12 +- tests/unit_tests/test_device_signal_input.py | 59 +++++++++- tests/unit_tests/test_scatter_waveform.py | 106 +++++++++++++++++- 6 files changed, 200 insertions(+), 37 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 7f82ef2ef..8dcb29d31 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -5557,6 +5557,15 @@ def signals(self) -> list[str]: list[str]: List of device signals. """ + @rpc_call + def get_signal_name(self) -> "str": + """ + Get the signal name from the combobox. + + Returns: + str: The signal name. + """ + class SignalLabel(RPCBase): @property diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 134bc8360..c895b9db9 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -27,7 +27,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. """ - USER_ACCESS = ["set_signal", "set_device", "signals"] + USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"] ICON_NAME = "list_alt" PLUGIN = True @@ -148,6 +148,24 @@ def set_to_first_enabled(self) -> bool: return True return False + def get_signal_name(self) -> str: + """ + Get the signal name from the combobox. + + Returns: + str: The signal name. + """ + signal_name = self.currentText() + index = self.findText(signal_name) + if index == -1: + return signal_name + + signal_info = self.itemData(index) + if signal_info: + signal_name = signal_info.get("obj_name", signal_name) + + return signal_name if signal_name else "" + @SafeSlot() def reset_selection(self): """Reset the selection of the combobox.""" diff --git a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py index 84dfa6106..c55d34064 100644 --- a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py +++ b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout @@ -9,11 +8,6 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.settings_dialog import SettingWidget -if TYPE_CHECKING: - from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( - SignalComboBox, - ) - class HeatmapSettings(SettingWidget): def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs): @@ -120,36 +114,17 @@ def fetch_all_properties(self): getattr(self.target_widget._image_config, "enforce_interpolation", False) ) - def _get_signal_name(self, signal: SignalComboBox) -> str: - """ - Get the signal name from the signal combobox. - Args: - signal (SignalComboBox): The signal combobox to get the name from. - Returns: - str: The signal name. - """ - device_entry = signal.currentText() - index = signal.findText(device_entry) - if index == -1: - return device_entry - - device_entry_info = signal.itemData(index) - if device_entry_info: - device_entry = device_entry_info.get("obj_name", device_entry) - - return device_entry if device_entry else "" - @SafeSlot() def accept_changes(self): """ Apply all properties from the settings widget to the target widget. """ x_name = self.ui.x_name.currentText() - x_entry = self._get_signal_name(self.ui.x_entry) + x_entry = self.ui.x_entry.get_signal_name() y_name = self.ui.y_name.currentText() - y_entry = self._get_signal_name(self.ui.y_entry) + y_entry = self.ui.y_entry.get_signal_name() z_name = self.ui.z_name.currentText() - z_entry = self._get_signal_name(self.ui.z_entry) + z_entry = self.ui.z_entry.get_signal_name() validate_bec = self.ui.validate_bec.checked color_map = self.ui.color_map.colormap interpolation = self.ui.interpolation.currentText() diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py index c0aa6c0db..475181c51 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py @@ -103,12 +103,12 @@ def accept_changes(self): """ Apply all properties from the settings widget to the target widget. """ - x_name = self.ui.x_name.text() - x_entry = self.ui.x_entry.text() - y_name = self.ui.y_name.text() - y_entry = self.ui.y_entry.text() - z_name = self.ui.z_name.text() - z_entry = self.ui.z_entry.text() + x_name = self.ui.x_name.currentText() + x_entry = self.ui.x_entry.get_signal_name() + y_name = self.ui.y_name.currentText() + y_entry = self.ui.y_entry.get_signal_name() + z_name = self.ui.z_name.currentText() + z_entry = self.ui.z_entry.get_signal_name() validate_bec = self.ui.validate_bec.checked color_map = self.ui.color_map.colormap diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index 65bffd238..ec2426fd7 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -4,7 +4,6 @@ from bec_lib.device import Signal from qtpy.QtWidgets import QWidget -from bec_widgets.tests.utils import FakeDevice from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( @@ -153,3 +152,61 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client): widget.deleteLater() mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register) + + +def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox): + """Test get_signal_name returns obj_name from item data when available.""" + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.set_device("samx") + + # Select a signal that has item data with obj_name + device_signal_combobox.setCurrentText("samx (readback)") + + # get_signal_name should return the obj_name from item data + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "samx" + + +def test_signal_combobox_get_signal_name_without_item_data(qtbot, device_signal_combobox): + """Test get_signal_name returns currentText when no item data available.""" + # Add a custom item without item data + device_signal_combobox.addItem("custom_signal") + device_signal_combobox.setCurrentText("custom_signal") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "custom_signal" + + +def test_signal_combobox_get_signal_name_not_found(qtbot, device_signal_combobox): + """Test get_signal_name when text is not found in combobox (index == -1).""" + # Set editable to allow text that's not in items + device_signal_combobox.setEditable(True) + device_signal_combobox.setCurrentText("nonexistent_signal") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "nonexistent_signal" + + +def test_signal_combobox_get_signal_name_empty(qtbot, device_signal_combobox): + """Test get_signal_name when combobox is empty.""" + device_signal_combobox.clear() + device_signal_combobox.setEditable(True) + device_signal_combobox.setCurrentText("") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "" + + +def test_signal_combobox_get_signal_name_with_velocity(qtbot, device_signal_combobox): + """Test get_signal_name with velocity signal.""" + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.include_config_signals = True + device_signal_combobox.set_device("samx") + + # Select velocity signal + device_signal_combobox.setCurrentText("velocity") + + signal_name = device_signal_combobox.get_signal_name() + assert signal_name == "samx_velocity" diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 95d950a6c..9abb01b03 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -1,4 +1,4 @@ -import json +from unittest.mock import patch import numpy as np @@ -7,6 +7,9 @@ ScatterDeviceSignal, ) from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform +from bec_widgets.widgets.plots.scatter_waveform.settings.scatter_curve_setting import ( + ScatterCurveSettings, +) from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client from .conftest import create_widget @@ -460,3 +463,104 @@ def test_device_properties_with_none_values(qtbot, mocked_client): # Entry None should not change anything swf.y_device_entry = None assert swf.y_device_entry # Should still have validated entry + + +################################################################################ +# ScatterCurveSettings Tests +################################################################################ + + +def test_scatter_curve_settings_accept_changes(qtbot, mocked_client): + """Test that accept_changes correctly extracts data from widgets and calls plot().""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set up the widgets with test values + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + + # Mock the plot method to verify it gets called with correct arguments + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + + # Verify plot was called + mock_plot.assert_called_once() + + # Get the call arguments + call_kwargs = mock_plot.call_args[1] + + # Verify device names were extracted correctly + assert call_kwargs["x_name"] == "samx" + assert call_kwargs["y_name"] == "samy" + assert call_kwargs["z_name"] == "bpm4i" + + +def test_scatter_curve_settings_accept_changes_with_entries(qtbot, mocked_client): + """Test that accept_changes correctly extracts signal entries from SignalComboBox.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set devices first to populate signal comboboxes + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + qtbot.wait(100) # Allow time for signals to populate + + # Mock the plot method + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + + mock_plot.assert_called_once() + call_kwargs = mock_plot.call_args[1] + + # Verify entries are extracted (will use get_signal_name()) + assert "x_entry" in call_kwargs + assert "y_entry" in call_kwargs + assert "z_entry" in call_kwargs + + +def test_scatter_curve_settings_accept_changes_color_map(qtbot, mocked_client): + """Test that accept_changes correctly extracts color_map from widget.""" + + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # Create the settings widget + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Set devices + settings.ui.x_name.set_device("samx") + settings.ui.y_name.set_device("samy") + settings.ui.z_name.set_device("bpm4i") + + # Get the current colormap + color_map = settings.ui.color_map.colormap + + with patch.object(swf, "plot") as mock_plot: + settings.accept_changes() + call_kwargs = mock_plot.call_args[1] + assert call_kwargs["color_map"] == color_map + + +def test_scatter_curve_settings_fetch_all_properties(qtbot, mocked_client): + """Test that fetch_all_properties correctly populates the settings from target widget.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + # First set up the scatter waveform with some data + swf.plot(x_name="samx", y_name="samy", z_name="bpm4i") + + # Create the settings widget - it should fetch properties automatically + settings = ScatterCurveSettings(parent=None, target_widget=swf, popup=True) + qtbot.addWidget(settings) + + # Verify the settings widget has fetched the values + assert settings.ui.x_name.currentText() == "samx" + assert settings.ui.y_name.currentText() == "samy" + assert settings.ui.z_name.currentText() == "bpm4i" From 953760c828b35a4e91c26f48709433e97c1c74c2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 18 Jan 2026 14:42:35 +0100 Subject: [PATCH 094/155] fix(scatter_waveform): remove curve_json from the properties --- bec_widgets/cli/client.py | 7 ------ .../scatter_waveform/scatter_waveform.py | 24 ------------------- .../end-2-end/test_plotting_framework_e2e.py | 4 ---- tests/unit_tests/test_scatter_waveform.py | 22 ----------------- 4 files changed, 57 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 8dcb29d31..d5d36267b 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -5372,13 +5372,6 @@ def screenshot(self, file_name: "str | None" = None): Take a screenshot of the dock area and save it to a file. """ - @property - @rpc_call - def main_curve(self) -> "ScatterCurve": - """ - The main scatter curve item. - """ - @property @rpc_call def color_map(self) -> "str": diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 05278c182..48a20f147 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -1,7 +1,5 @@ from __future__ import annotations -import json - import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -45,7 +43,6 @@ class ScatterWaveform(PlotBase): USER_ACCESS = [ *PlotBase.USER_ACCESS, # Scatter Waveform Specific RPC Access - "main_curve", "color_map", "color_map.setter", "plot", @@ -205,27 +202,6 @@ def color_map(self, value: str): except ValidationError: return - @SafeProperty(str, designable=False, popup_error=True) - def curve_json(self) -> str: - """ - Get the curve configuration as a JSON string. - """ - return json.dumps(self.main_curve.config.model_dump(), indent=2) - - @curve_json.setter - def curve_json(self, value: str): - """ - Set the curve configuration from a JSON string. - - Args: - value(str): The JSON string to set the curve configuration from. - """ - try: - config = ScatterCurveConfig(**json.loads(value)) - self._add_main_scatter_curve(config) - except json.JSONDecodeError as e: - logger.error(f"Failed to decode JSON: {e}") - ################################################################################ # High Level methods for API ################################################################################ diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index f453f6273..eddbbf29f 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -44,11 +44,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): im.image(monitor="eiger") mm.map(x_name="samx", y_name="samy") - sw.plot(x_name="samx", y_name="samy", z_name="bpm4i") - assert sw.main_curve.object_name == "bpm4i_bpm4i" - # Create a new curve on the scatter waveform should replace the old one sw.plot(x_name="samx", y_name="samy", z_name="bpm4a") - assert sw.main_curve.object_name == "bpm4a_bpm4a" mw.plot(monitor="waveform") # Adding multiple custom curves sho diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 9abb01b03..f0ba5620a 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -49,28 +49,6 @@ def test_scatter_waveform_color_map(qtbot, mocked_client): assert swf.color_map == "plasma" -def test_scatter_waveform_curve_json(qtbot, mocked_client): - swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) - - # Add a device-based scatter curve - swf.plot(x_name="samx", y_name="samy", z_name="bpm4i", label="test_curve") - - json_str = swf.curve_json - data = json.loads(json_str) - assert isinstance(data, dict) - assert data["label"] == "test_curve" - assert data["x_device"]["name"] == "samx" - assert data["y_device"]["name"] == "samy" - assert data["z_device"]["name"] == "bpm4i" - - # Clear and reload from JSON - swf.clear_all() - assert swf.main_curve.getData() == (None, None) - - swf.curve_json = json_str - assert swf.main_curve.config.label == "test_curve" - - def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeypatch): swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) From 48e2a97ece2469b2dafb3902dbf0134d4d3124b6 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sun, 18 Jan 2026 16:40:26 +0100 Subject: [PATCH 095/155] fix(scatter waveform): fix tab order for settings panel --- .../scatter_curve_settings_horizontal.ui | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui index 945c11d49..c8527291c 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui @@ -166,26 +166,34 @@ - DeviceComboBox + ToggleSwitch -
device_combo_box
+
toggle_switch
- SignalComboBox + BECColorMapWidget -
signal_combo_box
+
bec_color_map_widget
- ToggleSwitch + DeviceComboBox -
toggle_switch
+
device_combo_box
- BECColorMapWidget + SignalComboBox -
bec_color_map_widget
+
signal_combo_box
+ + x_name + y_name + z_name + x_entry + y_entry + z_entry + From 45e9f03093be2ebfd860f6d296d4766925acd807 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 20 Jan 2026 13:03:07 +0100 Subject: [PATCH 096/155] feat(toolbar): splitter action added --- bec_widgets/utils/toolbars/actions.py | 77 ++++++++ bec_widgets/utils/toolbars/bundles.py | 87 +++++++- bec_widgets/utils/toolbars/splitter.py | 241 +++++++++++++++++++++++ bec_widgets/utils/toolbars/toolbar.py | 76 +++++-- tests/unit_tests/test_modular_toolbar.py | 127 ++++++++++++ 5 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 bec_widgets/utils/toolbars/splitter.py diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 5c0b0955e..f82753ffe 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -26,6 +26,7 @@ ) import bec_widgets +from bec_widgets.utils.toolbars.splitter import ResizableSpacer from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox @@ -498,6 +499,82 @@ def calculate_minimum_width(combo_box: QComboBox) -> int: return max_width + 60 +class SplitterAction(ToolBarAction): + """ + Action for adding a draggable splitter/spacer to the toolbar. + + This creates a resizable spacer that allows users to control how much space + is allocated to toolbar sections before and after it. When dragged, it expands/contracts, + pushing other toolbar elements left or right. + + Args: + orientation (Literal["horizontal", "vertical", "auto"]): The orientation of the splitter. + parent (QWidget): The parent widget. + initial_width (int): Fixed size of the spacer in pixels along the toolbar's orientation (default: 20). + min_width (int | None): Minimum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no minimum constraint is applied. + max_width (int | None): Maximum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no maximum constraint is applied. + target_widget (QWidget | None): Widget whose size (width or height, depending on orientation) is controlled by the spacer within the given min/max bounds. + """ + + def __init__( + self, + orientation: Literal["horizontal", "vertical", "auto"] = "auto", + parent=None, + initial_width=20, + min_width: int | None = None, + max_width: int | None = None, + target_widget=None, + ): + super().__init__(icon_path=None, tooltip="Drag to resize toolbar sections", checkable=False) + self.orientation = orientation + self.initial_width = initial_width + self.min_width = min_width + self.max_width = max_width + self._splitter_widget = None + self._target_widget = target_widget + + def _resolve_orientation(self, toolbar: QToolBar) -> Literal["horizontal", "vertical"]: + if self.orientation in (None, "auto"): + return ( + "horizontal" if toolbar.orientation() == Qt.Orientation.Horizontal else "vertical" + ) + return self.orientation + + def set_target_widget(self, widget): + """Set the target widget after creation.""" + self._target_widget = widget + if self._splitter_widget: + self._splitter_widget.set_target_widget(widget) + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + """ + Adds the splitter/spacer to the toolbar. + + Args: + toolbar (QToolBar): The toolbar to add the splitter to. + target (QWidget): The target widget for the action. + """ + + effective_orientation = self._resolve_orientation(toolbar) + self._splitter_widget = ResizableSpacer( + parent=target, + orientation=effective_orientation, + initial_width=self.initial_width, + min_target_size=self.min_width, + max_target_size=self.max_width, + target_widget=self._target_widget, + ) + toolbar.addWidget(self._splitter_widget) + self.action = self._splitter_widget # type: ignore + + def cleanup(self): + """Clean up the splitter widget.""" + if self._splitter_widget is not None: + self._splitter_widget.close() + self._splitter_widget.deleteLater() + return super().cleanup() + + class ExpandableMenuAction(ToolBarAction): """ Action for an expandable menu in the toolbar. diff --git a/bec_widgets/utils/toolbars/bundles.py b/bec_widgets/utils/toolbars/bundles.py index 36876995d..5c312c6bb 100644 --- a/bec_widgets/utils/toolbars/bundles.py +++ b/bec_widgets/utils/toolbars/bundles.py @@ -7,10 +7,17 @@ import louie from bec_lib.logger import bec_logger from pydantic import BaseModel +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QSizePolicy -from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction +from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction + +DEFAULT_SIZE = 400 +MAX_SIZE = 10_000_000 if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -195,6 +202,84 @@ def add_separator(self): """ self.add_action("separator") + def add_splitter( + self, + name: str = "splitter", + target_widget: QWidget | None = None, + initial_width: int = 10, + min_width: int | None = None, + max_width: int | None = None, + size_policy_expanding: bool = True, + ): + """ + Adds a resizable splitter action to the bundle. + + Args: + name (str): Unique identifier for the splitter action. + target_widget (QWidget, optional): The widget whose size (width for horizontal, + height for vertical orientation) will be controlled by the splitter. If None, + the splitter will not control any widget. + initial_width (int): The initial size of the splitter (width for horizontal, + height for vertical orientation). + min_width (int, optional): The minimum size the target widget can be resized to + (width for horizontal, height for vertical orientation). If None, the target + widget's minimum size hint in that orientation will be used. + max_width (int, optional): The maximum size the target widget can be resized to + (width for horizontal, height for vertical orientation). If None, the target + widget's maximum size hint in that orientation will be used. + size_policy_expanding (bool): If True, the size policy of the target_widget will be + set to Expanding in the appropriate orientation if it is not already set. + """ + + # Resolve effective bounds + eff_min = min_width if min_width is not None else None + eff_max = max_width if max_width is not None else None + + is_horizontal = self.components.toolbar.orientation() == Qt.Orientation.Horizontal + + if target_widget is not None: + # Use widget hints if bounds not provided + if eff_min is None: + eff_min = ( + target_widget.minimumWidth() if is_horizontal else target_widget.minimumHeight() + ) or 6 + if eff_max is None: + mw = ( + target_widget.maximumWidth() if is_horizontal else target_widget.maximumHeight() + ) + eff_max = mw if mw and mw < MAX_SIZE else DEFAULT_SIZE # avoid "no limit" + + # Adjust size policy if needed + if size_policy_expanding: + size_policy = target_widget.sizePolicy() + + if is_horizontal: + if size_policy.horizontalPolicy() not in ( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.MinimumExpanding, + ): + size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) + target_widget.setSizePolicy(size_policy) + else: + if size_policy.verticalPolicy() not in ( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.MinimumExpanding, + ): + size_policy.setVerticalPolicy(QSizePolicy.Policy.Expanding) + target_widget.setSizePolicy(size_policy) + + splitter_action = SplitterAction( + orientation="auto", + parent=self.components.toolbar, + initial_width=initial_width, + min_width=eff_min, + max_width=eff_max, + target_widget=target_widget, + ) + + self.components.add_safe(name, splitter_action) + self.add_action(name) + def add_connection(self, name: str, connection): """ Adds a connection to the bundle. diff --git a/bec_widgets/utils/toolbars/splitter.py b/bec_widgets/utils/toolbars/splitter.py new file mode 100644 index 000000000..91805fc16 --- /dev/null +++ b/bec_widgets/utils/toolbars/splitter.py @@ -0,0 +1,241 @@ +""" +Draggable splitter for toolbars to allow resizing of toolbar sections. +""" + +from typing import Literal + +from bec_qthemes import material_icon +from qtpy.QtCore import QPoint, QSize, Qt, Signal +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QSizePolicy, QWidget + + +class ResizableSpacer(QWidget): + """ + A resizable spacer widget for toolbars that can be dragged to expand/contract. + + When connected to a widget, it controls that widget's size along the spacer's + orientation (maximum width for horizontal, maximum height for vertical), + ensuring the widget stays flush against the spacer with no gaps. + + Args: + parent(QWidget | None): Parent widget. + orientation(Literal["horizontal", "vertical"]): Orientation of the spacer. + initial_width(int): Initial size of the spacer in pixels along the orientation + (width for horizontal, height for vertical). + min_target_size(int): Minimum size of the target widget when resized along the + orientation (width for horizontal, height for vertical). + max_target_size(int): Maximum size of the target widget when resized along the + orientation (width for horizontal, height for vertical). + target_widget: QWidget | None. The widget whose size along the orientation + is controlled by this spacer. + """ + + size_changed = Signal(int) + + def __init__( + self, + parent=None, + orientation: Literal["horizontal", "vertical"] = "horizontal", + initial_width: int = 10, + min_target_size: int = 6, + max_target_size: int = 500, + target_widget: QWidget = None, + ): + from bec_widgets.utils.toolbars.bundles import DEFAULT_SIZE, MAX_SIZE + + super().__init__(parent) + self._target_start_size = None + self.orientation = orientation + self._current_width = initial_width + self._min_width = min_target_size + self._max_width = max_target_size + self._dragging = False + self._drag_start_pos = QPoint() + self._target_widget = target_widget + + # Determine bounds from kwargs or target hints + is_horizontal = orientation == "horizontal" + target_min = target_widget.minimumWidth() if (target_widget and is_horizontal) else 0 + if target_widget and not is_horizontal: + target_min = target_widget.minimumHeight() + target_hint = target_widget.sizeHint().width() if (target_widget and is_horizontal) else 0 + if target_widget and not is_horizontal: + target_hint = target_widget.sizeHint().height() + target_max_hint = ( + target_widget.maximumWidth() if (target_widget and is_horizontal) else None + ) + if target_widget and not is_horizontal: + target_max_hint = target_widget.maximumHeight() + self._min_target = min_target_size if min_target_size is not None else (target_min or 6) + self._max_target = ( + max_target_size + if max_target_size is not None + else ( + target_max_hint if target_max_hint and target_max_hint < MAX_SIZE else DEFAULT_SIZE + ) + ) + + # Determine a reasonable base width and clamp to bounds + if target_widget: + current_size = target_widget.width() if is_horizontal else target_widget.height() + if current_size > 0: + self._base_width = current_size + elif target_min > 0: + self._base_width = target_min + elif target_hint > 0: + self._base_width = target_hint + else: + self._base_width = 240 + else: + self._base_width = 240 + self._base_width = max(self._min_target, min(self._max_target, self._base_width)) + + # Set size constraints - Fixed policy to prevent automatic resizing + # Match toolbar height for proper alignment + self._toolbar_height = 32 # Standard toolbar height + + if orientation == "horizontal": + self.setFixedWidth(initial_width) + self.setFixedHeight(self._toolbar_height) + self.setCursor(Qt.CursorShape.SplitHCursor) + else: + self.setFixedHeight(initial_width) + self.setFixedWidth(self._toolbar_height) + self.setCursor(Qt.CursorShape.SplitVCursor) + + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self.setStyleSheet( + """ + ResizableSpacer { + background-color: transparent; + margin: 0px; + padding: 0px; + border: none; + } + ResizableSpacer:hover { + background-color: rgba(100, 100, 200, 80); + } + """ + ) + + self.setContentsMargins(0, 0, 0, 0) + + if self._target_widget: + size_policy = self._target_widget.sizePolicy() + if is_horizontal: + vertical_policy = size_policy.verticalPolicy() + self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy) + else: + horizontal_policy = size_policy.horizontalPolicy() + self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed) + + # Load Material icon based on orientation + icon_name = "more_vert" if orientation == "horizontal" else "more_horiz" + icon_size = 24 + self._icon = material_icon(icon_name, size=(icon_size, icon_size), convert_to_pixmap=False) + self._icon_size = icon_size + + def set_target_widget(self, widget): + """Set the widget whose size is controlled by this spacer.""" + self._target_widget = widget + if widget: + is_horizontal = self.orientation == "horizontal" + target_min = widget.minimumWidth() if is_horizontal else widget.minimumHeight() + target_hint = widget.sizeHint().width() if is_horizontal else widget.sizeHint().height() + target_max_hint = widget.maximumWidth() if is_horizontal else widget.maximumHeight() + self._min_target = self._min_target or (target_min or 6) + self._max_target = ( + self._max_target + if self._max_target is not None + else (target_max_hint if target_max_hint and target_max_hint < 10_000_000 else 400) + ) + current_size = widget.width() if is_horizontal else widget.height() + if current_size is not None and current_size > 0: + base = current_size + elif target_min is not None and target_min > 0: + base = target_min + elif target_hint is not None and target_hint > 0: + base = target_hint + else: + base = self._base_width + base = max(self._min_target, min(self._max_target, base)) + if is_horizontal: + widget.setFixedWidth(base) + else: + widget.setFixedHeight(base) + + def get_target_widget(self): + """Get the widget whose size is controlled by this spacer.""" + return self._target_widget + + def sizeHint(self): + if self.orientation == "horizontal": + return QSize(self._current_width, self._toolbar_height) + else: + return QSize(self._toolbar_height, self._current_width) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Draw the Material icon centered in the widget using stored icon size + x = (self.width() - self._icon_size) // 2 + y = (self.height() - self._icon_size) // 2 + + self._icon.paint(painter, x, y, self._icon_size, self._icon_size) + painter.end() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._dragging = True + self._drag_start_pos = event.globalPos() + # Store target's current width if it exists + if self._target_widget: + if self.orientation == "horizontal": + self._target_start_size = self._target_widget.width() + else: + self._target_start_size = self._target_widget.height() + + size_policy = self._target_widget.sizePolicy() + if self.orientation == "horizontal": + vertical_policy = size_policy.verticalPolicy() + self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy) + self._target_widget.setFixedWidth(self._target_start_size) + else: + horizontal_policy = size_policy.horizontalPolicy() + self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed) + self._target_widget.setFixedHeight(self._target_start_size) + + event.accept() + + def mouseMoveEvent(self, event): + if self._dragging: + current_pos = event.globalPos() + delta = current_pos - self._drag_start_pos + + if self.orientation == "horizontal": + delta_pixels = delta.x() + else: + delta_pixels = delta.y() + + if self._target_widget: + new_target_size = self._target_start_size + delta_pixels + new_target_size = max(self._min_target, min(self._max_target, new_target_size)) + + if self.orientation == "horizontal": + if new_target_size != self._target_widget.width(): + self._target_widget.setFixedWidth(new_target_size) + self.size_changed.emit(new_target_size) + else: + if new_target_size != self._target_widget.height(): + self._target_widget.setFixedHeight(new_target_size) + self.size_changed.emit(new_target_size) + + event.accept() + + def mouseReleaseEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._dragging = False + event.accept() diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index 4b10fba84..fc2ec7ad9 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -8,10 +8,19 @@ from bec_lib.logger import bec_logger from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QAction, QColor -from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QLabel, + QMainWindow, + QMenu, + QToolBar, + QVBoxLayout, + QWidget, +) from bec_widgets.utils.colors import apply_theme, get_theme_name -from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction +from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection @@ -406,9 +415,18 @@ def toggle_action_visibility(self, action_id: str, visible: bool | None = None): def update_separators(self): """ - Hide separators that are adjacent to another separator or have no non-separator actions between them. + Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them. + Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them. """ + from bec_widgets.utils.toolbars.splitter import ResizableSpacer + toolbar_actions = self.actions() + + # Helper function to check if a widget is a splitter + def is_splitter_widget(action): + widget = self.widgetForAction(action) + return widget is not None and isinstance(widget, ResizableSpacer) + # First pass: set visibility based on surrounding non-separator actions. for i, action in enumerate(toolbar_actions): if not action.isSeparator(): @@ -423,23 +441,32 @@ def update_separators(self): if toolbar_actions[j].isVisible(): next_visible = toolbar_actions[j] break - if (prev_visible is None or prev_visible.isSeparator()) and ( - next_visible is None or next_visible.isSeparator() + + # Hide separator if adjacent to another separator, splitter, or at edges + if ( + prev_visible is None + or prev_visible.isSeparator() + or is_splitter_widget(prev_visible) + ) and ( + next_visible is None + or next_visible.isSeparator() + or is_splitter_widget(next_visible) ): action.setVisible(False) else: action.setVisible(True) - # Second pass: ensure no two visible separators are adjacent. + # Second pass: ensure no two visible separators are adjacent, and no separators next to splitters. prev = None for action in toolbar_actions: - if action.isVisible() and action.isSeparator(): - if prev and prev.isSeparator(): - action.setVisible(False) + if action.isVisible(): + if action.isSeparator(): + # Hide separator if previous visible item was a separator or splitter + if prev and (prev.isSeparator() or is_splitter_widget(prev)): + action.setVisible(False) + else: + prev = action else: prev = action - else: - if action.isVisible(): - prev = action if not toolbar_actions: return @@ -481,12 +508,31 @@ def __init__(self): self.setWindowTitle("Toolbar / ToolbarBundle Demo") self.central_widget = QWidget() self.setCentralWidget(self.central_widget) - self.test_label = QLabel(text="This is a test label.") + self.test_label = QLabel(text="Drag the splitter (⋮) to resize!") self.central_widget.layout = QVBoxLayout(self.central_widget) self.central_widget.layout.addWidget(self.test_label) self.toolbar = ModularToolBar(parent=self) self.addToolBar(self.toolbar) + + # Example: Bare combobox (no container). Give it a stable starting width + self.example_combo = QComboBox(parent=self) + self.example_combo.addItems(["device_1", "device_2", "device_3"]) + + self.toolbar.components.add_safe( + "example_combo", WidgetAction(widget=self.example_combo) + ) + + # Create a bundle with the combobox and a splitter + self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components) + self.bundle_combo_splitter.add_action("example_combo") + # Add splitter; target the bare widget + self.bundle_combo_splitter.add_splitter( + name="splitter_example", target_widget=self.example_combo, min_width=100 + ) + + # Add other bundles + self.toolbar.add_bundle(self.bundle_combo_splitter) self.toolbar.add_bundle(performance_bundle(self.toolbar.components)) self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components)) self.toolbar.connect_bundle( @@ -502,7 +548,9 @@ def __init__(self): text_position="under", ), ) - self.toolbar.show_bundles(["performance", "plot_export"]) + + # Show bundles - notice how performance and plot_export appear compactly after splitter! + self.toolbar.show_bundles(["example_combo", "performance", "plot_export"]) self.toolbar.get_bundle("performance").add_action("save") self.toolbar.get_bundle("performance").add_action("text") self.toolbar.refresh() diff --git a/tests/unit_tests/test_modular_toolbar.py b/tests/unit_tests/test_modular_toolbar.py index e9238f7c9..307485d0a 100644 --- a/tests/unit_tests/test_modular_toolbar.py +++ b/tests/unit_tests/test_modular_toolbar.py @@ -16,6 +16,7 @@ WidgetAction, ) from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.splitter import ResizableSpacer from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -612,3 +613,129 @@ def test_remove_nonexistent_bundle(toolbar_fixture): with pytest.raises(KeyError) as excinfo: toolbar.remove_bundle("nonexistent_bundle") excinfo.match("Bundle with name 'nonexistent_bundle' does not exist.") + + +def _find_splitter_widget(toolbar: ModularToolBar) -> ResizableSpacer: + for action in toolbar.actions(): + widget = toolbar.widgetForAction(action) + if isinstance(widget, ResizableSpacer): + return widget + raise AssertionError("ResizableSpacer not found in toolbar actions.") + + +def test_add_splitter_auto_orientation(toolbar_fixture, qtbot): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle = toolbar.new_bundle("splitter_bundle") + bundle.add_action("combo_action") + bundle.add_splitter(name="splitter", target_widget=combo, min_width=80) + + toolbar.show_bundles(["splitter_bundle"]) + qtbot.wait(50) + + splitter_widget = _find_splitter_widget(toolbar) + if toolbar.orientation() == Qt.Horizontal: + assert splitter_widget.orientation == "horizontal" + assert splitter_widget.cursor().shape() == Qt.CursorShape.SplitHCursor + else: + assert splitter_widget.orientation == "vertical" + assert splitter_widget.cursor().shape() == Qt.CursorShape.SplitVCursor + + +def test_separator_hidden_next_to_splitter(toolbar_fixture, material_icon_action): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle_with_splitter = toolbar.new_bundle("bundle_with_splitter") + bundle_with_splitter.add_action("combo_action") + bundle_with_splitter.add_splitter(name="splitter", target_widget=combo, min_width=80) + + toolbar.components.add_safe("icon_action", material_icon_action) + bundle_next = toolbar.new_bundle("bundle_next") + bundle_next.add_action("icon_action") + + toolbar.show_bundles(["bundle_with_splitter", "bundle_next"]) + + actions = toolbar.actions() + splitter_index = None + for idx, action in enumerate(actions): + if isinstance(toolbar.widgetForAction(action), ResizableSpacer): + splitter_index = idx + break + assert splitter_index is not None + + separator_action = actions[splitter_index + 1] + assert separator_action.isSeparator() + assert not separator_action.isVisible() + + +def test_splitter_action_set_target_widget_after_show(toolbar_fixture, qtbot): + toolbar = toolbar_fixture + combo = QComboBox() + combo.addItems(["One", "Two", "Three"]) + combo_action = WidgetAction(label="Combo:", widget=combo) + toolbar.components.add_safe("combo_action", combo_action) + + bundle = toolbar.new_bundle("splitter_bundle") + bundle.add_action("combo_action") + bundle.add_splitter(name="splitter", min_width=80, max_width=160) + + toolbar.show_bundles(["splitter_bundle"]) + qtbot.wait(200) + + splitter_action = toolbar.components.get_action("splitter") + splitter_action.set_target_widget(combo) + + splitter_widget = _find_splitter_widget(toolbar) + if hasattr(splitter_widget, "get_target_widget"): + assert splitter_widget.get_target_widget() is combo + if splitter_widget.orientation == "horizontal": + assert 80 <= combo.width() <= 160 + else: + assert 80 <= combo.height() <= 160 + + +@pytest.mark.parametrize( + "orientation, delta", [("horizontal", QPoint(40, 0)), ("vertical", QPoint(0, 40))] +) +def test_splitter_mouse_events_resize_target(qtbot, orientation, delta): + from qtpy.QtWidgets import QVBoxLayout + + parent = QWidget() + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 0, 0) + + target = QComboBox() + target.addItems(["One", "Two", "Three"]) + layout.addWidget(target) + + splitter = ResizableSpacer( + parent=parent, + orientation=orientation, + initial_width=10, + min_target_size=60, + max_target_size=200, + target_widget=target, + ) + layout.addWidget(splitter) + + qtbot.addWidget(parent) + parent.show() + qtbot.waitExposed(parent) + + start_size = target.width() if orientation == "horizontal" else target.height() + + qtbot.mousePress(splitter, Qt.LeftButton, pos=splitter.rect().center()) + qtbot.mouseMove(splitter, splitter.rect().center() + delta) + qtbot.mouseRelease(splitter, Qt.LeftButton, pos=splitter.rect().center() + delta) + + end_size = target.width() if orientation == "horizontal" else target.height() + assert end_size != start_size + assert 60 <= end_size <= 200 From a219de11c10ef13646a60877282552a32cab2907 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 20 Jan 2026 13:03:07 +0100 Subject: [PATCH 097/155] feat(motor_map): motor selection adopted to splitter action --- .../toolbar_components/motor_selection.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index 2307fa765..ecd41627c 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -1,4 +1,4 @@ -from qtpy.QtWidgets import QHBoxLayout, QWidget +from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents @@ -8,6 +8,8 @@ class MotorSelection(QWidget): + """Motor selection widget for MotorMap toolbar.""" + def __init__(self, parent=None): super().__init__(parent=parent) @@ -17,18 +19,22 @@ def __init__(self, parent=None): self.motor_x.setToolTip("Select Motor X") self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) self.motor_x.setEditable(True) + self.motor_x.setMinimumWidth(60) + self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_y.addItem("", None) self.motor_y.setCurrentText("") self.motor_y.setToolTip("Select Motor Y") self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) self.motor_y.setEditable(True) + self.motor_y.setMinimumWidth(60) + # Simple horizontal layout with stretch to fill space layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(self.motor_x) - layout.addWidget(self.motor_y) + layout.setSpacing(2) + layout.addWidget(self.motor_x, stretch=1) # Equal stretch + layout.addWidget(self.motor_y, stretch=1) # Equal stretch def set_motors(self, motor_x: str | None, motor_y: str | None) -> None: """Set the displayed motors without emitting selection signals.""" @@ -65,6 +71,9 @@ def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: """ Creates a workspace toolbar bundle for MotorMap. + Includes a resizable splitter after the motor selection. All subsequent bundles' + actions will appear compactly after the splitter with no gaps. + Args: components (ToolbarComponents): The components to be added to the bundle. @@ -79,6 +88,14 @@ def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: bundle = ToolbarBundle("motor_selection", components) bundle.add_action("motor_selection") + + bundle.add_splitter( + name="motor_selection_splitter", + target_widget=motor_selection_widget, + min_width=170, + max_width=400, + ) + return bundle From f9b92dacc3c491877ce5f609d28af9e24044e4e7 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 11:31:35 +0100 Subject: [PATCH 098/155] fix(bec_connector): use RPC register to fetch all connections --- bec_widgets/utils/bec_connector.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 91d53e333..8c225fe14 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional +import shiboken6 as shb from bec_lib.logger import bec_logger from bec_lib.utils.import_utils import lazy_import_from from pydantic import BaseModel, Field, field_validator @@ -230,23 +231,21 @@ def _enforce_unique_sibling_name(self): - If there's a nearest BECConnector parent, only compare with children of that parent. - If parent is None (i.e., top-level object), compare with all other top-level BECConnectors. """ + if not shb.isValid(self): + return + QApplication.sendPostedEvents() parent_bec = WidgetHierarchy._get_becwidget_ancestor(self) if parent_bec: # We have a parent => only compare with siblings under that parent - siblings = parent_bec.findChildren(BECConnector) + siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)] else: # No parent => treat all top-level BECConnectors as siblings - # 1) Gather all BECConnectors from QApplication - all_widgets = QApplication.allWidgets() - all_bec = [w for w in all_widgets if isinstance(w, BECConnector)] - # 2) "Top-level" means closest BECConnector parent is None - top_level_bec = [ - w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None - ] - # 3) We are among these top-level siblings - siblings = top_level_bec + # Use RPCRegister to avoid QApplication.allWidgets() during event processing. + connections = self.rpc_register.list_all_connections().values() + all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)] + siblings = [w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None] # Collect used names among siblings used_names = {sib.objectName() for sib in siblings if sib is not self} From 8102f3195603e4d3823b3c81916cdf64a6e9e8cb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 10:49:07 +0100 Subject: [PATCH 099/155] fix(positioner_box): layout HV centered and size taken from the ui file --- .../positioner_box/positioner_box/positioner_box.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 28d779999..f7cfaf4cc 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -7,7 +7,7 @@ from bec_lib.device import Positioner from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import Qt, Signal from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import QDoubleSpinBox @@ -66,6 +66,13 @@ def init_ui(self): self.addWidget(self.ui) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter) + ui_min_size = self.ui.minimumSize() + ui_min_hint = self.ui.minimumSizeHint() + self.setMinimumSize( + max(ui_min_size.width(), ui_min_hint.width()), + max(ui_min_size.height(), ui_min_hint.height()), + ) # fix the size of the device box db = self.ui.device_box From fd1edf81770ed7c4ccc2721da99a47c4fc8130b1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 13:08:50 +0100 Subject: [PATCH 100/155] fix: remove singleShots from BECConnector and adjustments of dock area logic --- bec_widgets/utils/bec_connector.py | 8 +++--- .../advanced_dock_area/basic_dock_area.py | 25 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 8c225fe14..0c720a090 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -186,7 +186,7 @@ def terminate(client=self.client, dispatcher=self.bec_dispatcher): # If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object. self.root_widget = root_widget - QTimer.singleShot(0, self._update_object_name) + self._update_object_name() @property def parent_id(self) -> str | None: @@ -207,7 +207,7 @@ def change_object_name(self, name: str) -> None: """ self.rpc_register.remove_rpc(self) self.setObjectName(name.replace("-", "_").replace(" ", "_")) - QTimer.singleShot(0, self._update_object_name) + self._update_object_name() def _update_object_name(self) -> None: """ @@ -220,7 +220,8 @@ def _update_object_name(self) -> None: self.rpc_register.add_rpc(self) try: self.name_established.emit(self.object_name) - except RuntimeError: + except RuntimeError as e: + logger.warning(f"Error emitting name_established signal: {e}") return def _enforce_unique_sibling_name(self): @@ -234,7 +235,6 @@ def _enforce_unique_sibling_name(self): if not shb.isValid(self): return - QApplication.sendPostedEvents() parent_bec = WidgetHierarchy._get_becwidget_ancestor(self) if parent_bec: diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index a3e71b0cd..4a398d4c8 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -6,7 +6,7 @@ from bec_lib import bec_logger from bec_qthemes import material_icon -from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer +from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from shiboken6 import isValid @@ -302,6 +302,13 @@ def _make_dock( dock = CDockWidget(self.dock_manager, widget.objectName(), self) dock.setWidget(widget) + widget_min_size = widget.minimumSize() + widget_min_hint = widget.minimumSizeHint() + dock_min_size = QSize( + max(widget_min_size.width(), widget_min_hint.width()), + max(widget_min_size.height(), widget_min_hint.height()), + ) + dock.setMinimumSize(dock_min_size) dock._dock_preferences = dict(dock_preferences or {}) dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) @@ -324,7 +331,9 @@ def on_widget_destroyed(): if hasattr(widget, "widget_removed"): widget.widget_removed.connect(on_widget_destroyed) - dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) + dock.setMinimumSizeHintMode( + CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize + ) dock_area_widget = None if tab_with is not None: if not isValid(tab_with): @@ -1302,11 +1311,13 @@ def new( apply_widget_icon=apply_widget_icon, ) - def _on_name_established(_name: str) -> None: - # Defer creation so BECConnector sibling name enforcement has completed. - QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) - - widget.name_established.connect(_on_name_established) + # def _on_name_established(_name: str) -> None: + # # Defer creation so BECConnector sibling name enforcement has completed. + # QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) + # print(f"[BasicDockArea] Deferred dock creation for '{_name}'") + # + # widget.name_established.connect(_on_name_established) + self._create_dock_from_spec(spec) return widget spec = self._build_creation_spec( From 6be6dafd7d37a70aabf5421cba19a17278737f3b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 13:30:45 +0100 Subject: [PATCH 101/155] fix(widgets): processEvent removed from widgets using it --- .../services/device_browser/device_item/device_config_dialog.py | 2 -- .../scan_history_browser/components/scan_history_view.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index ceaea99ad..704997b44 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -324,12 +324,10 @@ def update_error(self, e: Exception): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QApplication.processEvents() # TODO check if this kills performance and scheduling! def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QApplication.processEvents() # TODO check if this kills performance and scheduling! def main(): # pragma: no cover diff --git a/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py b/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py index 1a417f210..687d0b9cc 100644 --- a/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py +++ b/bec_widgets/widgets/services/scan_history_browser/components/scan_history_view.py @@ -177,12 +177,10 @@ def _add_overlay(self): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QtWidgets.QApplication.processEvents() def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QtWidgets.QApplication.processEvents() def _current_item_changed( self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem From 5e63814afebf35a30817b99f341469a3d0f15b23 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 14:19:15 +0100 Subject: [PATCH 102/155] fix(basic_dock_area): removed the singleShot usage --- .../containers/advanced_dock_area/basic_dock_area.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 4a398d4c8..5111619b8 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -1198,8 +1198,7 @@ def apply(): if button is not None: button.setVisible(bool(visible)) - # single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize - QTimer.singleShot(0, apply) + apply() def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None: """ @@ -1311,12 +1310,6 @@ def new( apply_widget_icon=apply_widget_icon, ) - # def _on_name_established(_name: str) -> None: - # # Defer creation so BECConnector sibling name enforcement has completed. - # QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) - # print(f"[BasicDockArea] Deferred dock creation for '{_name}'") - # - # widget.name_established.connect(_on_name_established) self._create_dock_from_spec(spec) return widget @@ -1422,7 +1415,7 @@ def delete_all(self): if __name__ == "__main__": # pragma: no cover import sys - from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton + from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton from bec_widgets.utils.colors import apply_theme From 4ef1344fec620280176e1002604986eee65094dd Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 14:43:25 +0100 Subject: [PATCH 103/155] fix(view):removed splitter logic --- bec_widgets/applications/views/view.py | 103 +------------------------ 1 file changed, 1 insertion(+), 102 deletions(-) diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py index 635f68b15..3b98f7568 100644 --- a/bec_widgets/applications/views/view.py +++ b/bec_widgets/applications/views/view.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import List - -from qtpy.QtCore import QEventLoop, Qt, QTimer +from qtpy.QtCore import QEventLoop from qtpy.QtWidgets import ( QDialog, QDialogButtonBox, @@ -11,7 +9,6 @@ QLabel, QMessageBox, QPushButton, - QSplitter, QStackedLayout, QVBoxLayout, QWidget, @@ -23,42 +20,6 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform -def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: - """ - Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. - Works for horizontal or vertical splitters and sets matching stretch factors. - """ - - def apply(): - n = splitter.count() - if n == 0: - return - w = list(weights[:n]) + [1] * max(0, n - len(weights)) - w = [max(0.0, float(x)) for x in w] - tot_w = sum(w) - if tot_w <= 0: - w = [1.0] * n - tot_w = float(n) - total_px = ( - splitter.width() - if splitter.orientation() == Qt.Orientation.Horizontal - else splitter.height() - ) - if total_px < 2: - QTimer.singleShot(0, apply) - return - sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] - diff = total_px - sum(sizes) - if diff != 0: - idx = max(range(n), key=lambda i: w[i]) - sizes[idx] = max(1, sizes[idx] + diff) - splitter.setSizes(sizes) - for i, wi in enumerate(w): - splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) - - QTimer.singleShot(0, apply) - - class ViewBase(QWidget): """Wrapper for a content widget used inside the main app's stacked view. @@ -115,68 +76,6 @@ def on_exit(self) -> bool: """ return True - ####### Default view has to be done with setting up splitters ######## - def set_default_view(self, horizontal_weights: list, vertical_weights: list): - """Apply initial weights to every horizontal and vertical splitter. - - Examples: - horizontal_weights = [1, 3, 2, 1] - vertical_weights = [3, 7] # top:bottom = 30:70 - """ - splitters_h = [] - splitters_v = [] - for splitter in self.findChildren(QSplitter): - if splitter.orientation() == Qt.Orientation.Horizontal: - splitters_h.append(splitter) - elif splitter.orientation() == Qt.Orientation.Vertical: - splitters_v.append(splitter) - - def apply_all(): - for s in splitters_h: - set_splitter_weights(s, horizontal_weights) - for s in splitters_v: - set_splitter_weights(s, vertical_weights) - - QTimer.singleShot(0, apply_all) - - def set_stretch(self, *, horizontal=None, vertical=None): - """Update splitter weights and re-apply to all splitters. - - Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict - for convenience: horizontal roles = {"left","center","right"}, - vertical roles = {"top","bottom"}. - """ - - def _coerce_h(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [ - float(x.get("left", 1)), - float(x.get("center", x.get("middle", 1))), - float(x.get("right", 1)), - ] - return None - - def _coerce_v(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [float(x.get("top", 1)), float(x.get("bottom", 1))] - return None - - h = _coerce_h(horizontal) - v = _coerce_v(vertical) - if h is None: - h = [1, 1, 1] - if v is None: - v = [1, 1] - self.set_default_view(h, v) - #################################################################################################### # Example views for demonstration/testing purposes From f4c14d66db2a12a51bdcdf37297caa0d22dc7d4a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 14:51:35 +0100 Subject: [PATCH 104/155] fix(advanced_dock_area): removed the singleShot for load_initial_profile --- .../containers/advanced_dock_area/advanced_dock_area.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 4955b0da2..e9f85b9a8 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -246,11 +246,10 @@ def _fetch_initial_profile(self): if self._profile_exists("general", namespace): init_profile = "general" if init_profile: - # Defer initial load to the event loop so child widgets exist before state restore. - QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) + self._load_initial_profile(init_profile) def _load_initial_profile(self, name: str) -> None: - """Load the initial profile after construction when the event loop is running.""" + """Load the initial profile.""" self.load_profile(name, start_empty=self._start_empty) combo = self.toolbar.components.get_action("workspace_combo").widget combo.blockSignals(True) From e76dea6f69abc4c4fb977eae92c891f8a1d4fecf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 14:57:08 +0100 Subject: [PATCH 105/155] fix(launch_window): processEvents removed --- bec_widgets/applications/launch_window.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 8505b1b8c..25b2c0d48 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -510,7 +510,6 @@ def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow: window = BECMainWindow(object_name=filename) window.setCentralWidget(loaded) - QApplication.processEvents() window.setWindowTitle(f"BEC - {filename}") self._apply_window_geometry(window, None) window.show() @@ -527,7 +526,6 @@ def _launch_auto_update(self, auto_update: str) -> AutoUpdates: window = AutoUpdates() window.resize(window.minimumSizeHint()) - QApplication.processEvents() window.setWindowTitle(f"BEC - {window.objectName()}") self._apply_window_geometry(window, None) window.show() @@ -542,7 +540,6 @@ def _launch_widget(self, widget: type[BECWidget]) -> QWidget: widget_instance = widget(root_widget=True, object_name=name) assert isinstance(widget_instance, QWidget) - QApplication.processEvents() window.setCentralWidget(widget_instance) window.resize(window.minimumSizeHint()) From 5a594925f0bc80de98d617aadd89b7f6acadcd94 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 26 Jan 2026 15:01:59 +0100 Subject: [PATCH 106/155] fix(colors): added logger to the apply theme --- bec_widgets/utils/colors.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 236cd157a..6b55d7f53 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -5,6 +5,7 @@ import numpy as np import pyqtgraph as pg +from bec_lib import bec_logger from bec_qthemes import apply_theme as apply_theme_global from bec_qthemes._theme import AccentColors from pydantic_core import PydanticCustomError @@ -12,6 +13,8 @@ from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication +logger = bec_logger.logger + def get_theme_name(): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): @@ -47,6 +50,7 @@ def apply_theme(theme: Literal["dark", "light"]): """ Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally. """ + logger.info(f"Applying theme: {theme}") process_all_deferred_deletes(QApplication.instance()) apply_theme_global(theme) process_all_deferred_deletes(QApplication.instance()) From cc45fed387982dbe644d505b12ac51bf234ab868 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 8 Jan 2026 16:15:32 +0100 Subject: [PATCH 107/155] feat(device-initialization-progress-bar): add progress bar for device initialization --- .../__init__.py | 0 .../device_initialization_progress_bar.py | 126 ++++++++++++++++++ ...test_device_initialization_progress_bar.py | 66 +++++++++ 3 files changed, 192 insertions(+) create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/__init__.py create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py create mode 100644 tests/unit_tests/test_device_initialization_progress_bar.py diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/__init__.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py new file mode 100644 index 000000000..093aad717 --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -0,0 +1,126 @@ +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import DeviceInitializationProgressMessage +from qtpy.QtCore import Signal + +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar + + +class DeviceInitializationProgressBar(BECProgressBar): + """A progress bar that displays the progress of device initialization.""" + + # Signal emitted for failed device initializations + failed_devices_changed = Signal(list) + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + self._latest_device_config_msg: dict | None = None + self._failed_devices: list[str] = [] + self.bec_dispatcher.connect_slot( + slot=self._update_device_initialization_progress, + topics=MessageEndpoints.device_initialization_progress(), + ) + self._reset_progress_bar() + + @SafeProperty(list) + def failed_devices(self) -> list[str]: + """Get the list of devices that failed to initialize. + + Returns: + list[str]: A list of device identifiers that failed during initialization. + """ + return self._failed_devices + + @failed_devices.setter + def failed_devices(self, value: list[str]) -> None: + self._failed_devices = value + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot() + def reset_failed_devices(self) -> None: + """Reset the list of failed devices.""" + self._failed_devices.clear() + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot(str) + def add_failed_device(self, device: str) -> None: + """Add a device to the list of failed devices. + + Args: + device (str): The identifier of the device that failed to initialize. + """ + self._failed_devices.append(device) + self.failed_devices_changed.emit(self.failed_devices) + + @SafeSlot(dict, dict) + def _update_device_initialization_progress(self, msg: dict, metadata: dict) -> None: + """Update the progress bar based on device initialization progress messages. + + Args: + msg (dict): The device initialization progress message. + metadata (dict): Additional metadata about the message. + """ + msg: DeviceInitializationProgressMessage = ( + DeviceInitializationProgressMessage.model_validate(msg) + ) + if msg.finished is False: + self.label_template = "\n".join( + [ + f"Device initialization for '{msg.device}' is in progress...", + "$value / $maximum - $percentage %", + ] + ) + elif msg.finished is True and msg.success is False: + self.add_failed_device(msg.device) + self.label_template = "\n".join( + [ + f"Device initialization for '{msg.device}' failed!", + "$value / $maximum - $percentage %", + ] + ) + else: + self.label_template = "\n".join( + [ + f"Device initialization for '{msg.device}' succeeded!", + "$value / $maximum - $percentage %", + ] + ) + self.set_maximum(msg.total) + self.set_value(msg.index) + self._update_tool_tip() + + def _reset_progress_bar(self) -> None: + """Reset the progress bar to its initial state.""" + self.label_template = "\n".join( + ["Waiting for device initialization...", "$value / $maximum - $percentage %"] + ) + self.set_value(0) + self.set_maximum(1) + self.reset_failed_devices() + self._update_tool_tip() + + def _update_tool_tip(self) -> None: + """Update the tooltip to show failed devices if any.""" + if self._failed_devices: + failed_devices_str = ", ".join(sorted(self._failed_devices)) + self.setToolTip(f"Failed devices: {failed_devices_str}") + else: + self.setToolTip("No device initialization failures.") + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + + progressBar = DeviceInitializationProgressBar() + + def my_cb(devices: list): + print("Failed devices:", devices) + + progressBar.failed_devices_changed.connect(my_cb) + progressBar.show() + + sys.exit(app.exec()) diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py new file mode 100644 index 000000000..9904d9196 --- /dev/null +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -0,0 +1,66 @@ +# pylint skip +import pytest +from bec_lib.messages import DeviceInitializationProgressMessage + +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) + + +@pytest.fixture +def progress_bar(qtbot): + widget = DeviceInitializationProgressBar() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_progress_bar_initialization(progress_bar): + """Test the initial state of the DeviceInitializationProgressBar.""" + assert progress_bar.failed_devices == [] + assert progress_bar._user_value == 0 + assert progress_bar._user_maximum == 1 + assert progress_bar.toolTip() == "No device initialization failures." + + +def test_update_device_initialization_progress(progress_bar, qtbot): + """Test updating the progress bar with different device initialization messages.""" + + # I. Update with message of running DeviceInitializationProgressMessage, finished=False, success=False + msg = DeviceInitializationProgressMessage( + device="DeviceA", index=1, total=3, finished=False, success=False + ) + + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar._user_value == 1 + assert progress_bar._user_maximum == 3 + assert ( + f"Device initialization for '{msg.device}' is in progress..." + in progress_bar.center_label.text() + ) + + # II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True + msg.finished = True + msg.success = True + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar._user_value == 1 + assert progress_bar._user_maximum == 3 + assert ( + f"Device initialization for '{msg.device}' succeeded!" in progress_bar.center_label.text() + ) + + # III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False + msg.finished = True + msg.success = False + msg.device = "DeviceB" + msg.index = 2 + with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker: + progress_bar._update_device_initialization_progress(msg.model_dump(), {}) + assert progress_bar._user_value == 2 + assert progress_bar._user_maximum == 3 + assert ( + f"Device initialization for '{msg.device}' failed!" in progress_bar.center_label.text() + ) + assert signal_blocker.args == [[msg.device]] + + assert progress_bar.toolTip() == f"Failed devices: {msg.device}" From b38d6dc54946f7f93c3b71af216cfe0c1bd8fe27 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 9 Jan 2026 13:16:24 +0100 Subject: [PATCH 108/155] refactor(busy-loader): refactor busy loader to use custom widget --- .../device_form_dialog.py | 10 +- .../device_manager_display_widget.py | 229 +++++++++++++++--- bec_widgets/cli/client.py | 74 ++++++ bec_widgets/utils/bec_widget.py | 144 +++++++---- bec_widgets/utils/busy_loader.py | 197 ++++++++++----- .../components/device_table/device_table.py | 154 ++++++++++-- .../ophyd_validation/ophyd_validation.py | 55 +++-- .../device_initialization_progress_bar.py | 5 +- ...vice_initialization_progress_bar.pyproject | 1 + ...vice_initialization_progress_bar_plugin.py | 59 +++++ ...ster_device_initialization_progress_bar.py | 17 ++ .../device_item/config_communicator.py | 9 + tests/unit_tests/test_busy_loader.py | 53 ++-- ...test_device_initialization_progress_bar.py | 8 +- .../test_device_manager_components.py | 80 +++++- tests/unit_tests/test_device_manager_view.py | 34 +-- 16 files changed, 850 insertions(+), 279 deletions(-) create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 9d57daf0e..167cfbfff 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -6,6 +6,7 @@ from bec_lib.logger import bec_logger from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES from qtpy import QtCore, QtWidgets +from zmq.devices import Device from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.device_manager.components import OphydValidation @@ -56,8 +57,6 @@ def __init__(self, parent=None, config: dict | None = None): # type:ignore if device_name: self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name) - self.device_manager_ophyd_test.change_device_configs([config], True, True) - # Dialog Buttons: equal size, stacked horizontally button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) for button in button_box.buttons(): @@ -71,6 +70,9 @@ def __init__(self, parent=None, config: dict | None = None): # type:ignore self._resize_dialog() self.finished.connect(self._finished) + # Add and test device config + self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True) + def _resize_dialog(self): """Resize the dialog based on the screen size.""" app: QtCore.QCoreApplication = QtWidgets.QApplication.instance() @@ -285,7 +287,7 @@ def _create_validation_dialog(self) -> QtWidgets.QProgressDialog: The dialog will be modal and prevent user interaction until validation is complete. """ wait_dialog = QtWidgets.QProgressDialog( - "Validating config… please wait", None, 0, 0, parent=self + "Validating config... please wait", None, 0, 0, parent=self ) wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) wait_dialog.setCancelButton(None) @@ -368,7 +370,7 @@ def _add_config(self): if not validate_name(config.get("name", "")): msg_box = self._create_warning_message_box( "Invalid Device Name", - f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ", + f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}", ) msg_box.exec() return diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 8c3886645..d90e72d06 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -2,7 +2,7 @@ import os from functools import partial -from typing import List, Literal, get_args +from typing import TYPE_CHECKING, List, Literal, get_args import yaml from bec_lib import config_helper @@ -11,9 +11,17 @@ from bec_lib.logger import bec_logger from bec_lib.messages import ConfigAction from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path -from bec_qthemes import apply_theme -from qtpy.QtCore import QMetaObject, QThreadPool, Signal -from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget +from bec_qthemes import apply_theme, material_icon +from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal +from qtpy.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( ConfigChoiceDialog, @@ -22,6 +30,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( UploadRedisDialog, ) +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle @@ -38,9 +47,16 @@ ConfigStatus, ConnectionStatus, ) +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.client import BECClient logger = bec_logger.logger @@ -51,6 +67,74 @@ ) +class CustomBusyWidget(QWidget): + """Custom busy widget to show during device config upload.""" + + cancel_requested = Signal() + + def __init__(self, parent=None, client: BECClient | None = None): + super().__init__(parent=parent) + + # Widgets + progress = DeviceInitializationProgressBar(parent=self, client=client) + + # Spinner + spinner = SpinnerWidget(parent=self) + scale = self._ui_scale() + spinner_size = int(scale * 0.12) if scale else 1 + spinner_size = max(32, min(spinner_size, 64)) + spinner.setFixedSize(spinner_size, spinner_size) + + # Cancel button + cancel_button = QPushButton("Cancel Upload", parent=self) + cancel_button.setIcon(material_icon("cancel")) + cancel_button.clicked.connect(self.cancel_requested.emit) + button_height = int(spinner_size * 0.9) + button_height = max(36, min(button_height, 72)) + aspect_ratio = 3.8 # width / height, visually stable for text buttons + button_width = int(button_height * aspect_ratio) + cancel_button.setFixedSize(button_width, button_height) + color = get_accent_colors() + cancel_button.setStyleSheet( + f""" + QPushButton {{ + background-color: {color.emergency.name()}; + color: white; + font-weight: 600; + border-radius: 6px; + }} + """ + ) + + # Layout + content_layout = QVBoxLayout(self) + content_layout.setContentsMargins(24, 24, 24, 24) + content_layout.setSpacing(16) + content_layout.addStretch() + content_layout.addWidget(spinner, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addStretch() + content_layout.addWidget(cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) + + def _ui_scale(self) -> int: + parent = self.parent() + if not parent: + return 0 + return min(parent.width(), parent.height()) + + def showEvent(self, event): + """Show event to start the spinner.""" + super().showEvent(event) + for child in self.findChildren(SpinnerWidget): + child.start() + + def hideEvent(self, event): + """Hide event to stop the spinner.""" + super().hideEvent(event) + for child in self.findChildren(SpinnerWidget): + child.stop() + + class DeviceManagerDisplayWidget(DockAreaWidget): """Device Manager main display widget. This contains all sub-widgets and the toolbar.""" @@ -61,13 +145,22 @@ class DeviceManagerDisplayWidget(DockAreaWidget): def __init__(self, parent=None, *args, **kwargs): super().__init__(parent=parent, variant="compact", *args, **kwargs) + # State variable for config upload + self._config_upload_active: bool = False + # Push to Redis dialog self._upload_redis_dialog: UploadRedisDialog | None = None self._dialog_validation_connection: QMetaObject.Connection | None = None + # NOTE: We need here a seperate config helper instance to avoid conflicts with + # other communications to REDIS as uploading a config through a CommunicationConfigAction + # will block if we use the config_helper from self.client.config._config_helper self._config_helper = config_helper.ConfigHelper(self.client.connector) self._shared_selection = SharedSelectionSignal() + # Custom upload widget for busy overlay + self._custom_overlay_widget: QWidget | None = None + # Device Table View widget self.device_table_view = DeviceTable(self) @@ -125,6 +218,43 @@ def __init__(self, parent=None, *args, **kwargs): # Build dock layout using shared helpers self._build_docks() + logger.info("Connecting application about to quit signal to device manager view...") + QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler) + + ############################## + ### Custom set busy widget ### + ############################## + + def create_busy_state_widget(self) -> QWidget: + """Create a custom busy state widget for uploading device configurations.""" + widget = CustomBusyWidget(parent=self, client=self.client) + widget.cancel_requested.connect(self._cancel_device_config_upload) + return widget + + ################################ + ### Application quit handler ### + ################################ + + @SafeSlot() + def _about_to_quit_handler(self): + """Handle application about to quit event. If config upload is active, cancel it.""" + logger.info("Application is quitting, checking for active config upload...") + if self._config_upload_active: + logger.info("Application is quitting, cancelling active config upload...") + self._config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Config upload cancelled.") + + def _set_busy_wrapper(self, enabled: bool): + """Thin wrapper around set_busy to flip the state variable.""" + self._config_upload_active = enabled + self.set_busy(enabled=enabled) + + ############################## + ### Toolbar and Dock setup ### + ############################## + def _add_toolbar(self): self.toolbar = ModularToolBar(self) @@ -306,6 +436,10 @@ def _add_table_actions(self) -> None: # Add load config from plugin dir self.toolbar.add_bundle(table_bundle) + ####################### + ### Action Handlers ### + ####################### + @SafeSlot() @SafeSlot(bool) def _run_validate_connection(self, connect: bool = True): @@ -432,10 +566,8 @@ def _flush_redis_action(self): "Do you really want to flush the current config in BEC Server?", ) if reply == QMessageBox.StandardButton.Yes: - self.set_busy(enabled=True, text="Flushing configuration in BEC Server...") self.client.config.reset_config() logger.info("Successfully flushed configuration in BEC Server.") - self.set_busy(enabled=False) # Check if config is in sync, enable load redis button self.device_table_view.device_config_in_sync_with_redis.emit( self.device_table_view._is_config_in_sync_with_redis() @@ -511,12 +643,49 @@ def _push_composition_to_redis(self, action: ConfigAction): comm.signals.done.connect(self._handle_push_complete_to_communicator) comm.signals.error.connect(self._handle_exception_from_communicator) threadpool.start(comm) - self.set_busy(enabled=True, text="Uploading configuration to BEC Server...") + self._set_busy_wrapper(enabled=True) + + def _cancel_device_config_upload(self): + """Cancel the device configuration upload process.""" + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel") + # Cancelling will raise an exception in the communicator, so we connect to the failure handler + comm.signals.error.connect(self._handle_cancel_config_upload_failed) + threadpool.start(comm) + + def _handle_cancel_config_upload_failed(self, exception: Exception): + """Handle failure to cancel the config upload.""" + QMessageBox.critical(self, "Error Cancelling Upload", f"{str(exception)}") + self._set_busy_wrapper(enabled=False) + + validation_results = self.device_table_view.get_validation_results() + devices_to_update = [] + for config, config_status, connection_status in validation_results.values(): + devices_to_update.append( + (config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled") + ) + # Rerun validation of all devices after cancellation + self.device_table_view.update_multiple_device_validations(devices_to_update) + self.ophyd_test_view.change_device_configs( + [cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False + ) + # Config is in sync with BEC, so we update the state + self.device_table_view.device_config_in_sync_with_redis.emit(False) + + # Cleanup custom overlay widget + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None def _handle_push_complete_to_communicator(self): """Handle completion of the config push to Redis.""" - self.set_busy(enabled=False) - self._update_validation_icons_after_upload() + self._set_busy_wrapper(enabled=False) + # Cleanup custom overlay widget + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None def _handle_exception_from_communicator(self, exception: Exception): """Handle exceptions from the config communicator.""" @@ -525,29 +694,11 @@ def _handle_exception_from_communicator(self, exception: Exception): "Error Uploading Config", f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", ) - self.set_busy(enabled=False) - self._update_validation_icons_after_upload() - - def _update_validation_icons_after_upload(self): - """Update validation icons after uploading config to Redis.""" - if self.client.device_manager is None: - return - device_names_in_session = list(self.client.device_manager.devices.keys()) - validation_results = self.device_table_view.get_validation_results() - devices_to_update = [] - for config, config_status, connection_status in validation_results.values(): - if config["name"] in device_names_in_session: - devices_to_update.append( - (config, config_status, ConnectionStatus.CONNECTED.value, "") - ) - # Update validation status in device table view - self.device_table_view.update_multiple_device_validations(devices_to_update) - # Remove devices from ophyd validation view - self.ophyd_test_view.change_device_configs( - [cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True - ) - # Config is in sync with BEC, so we update the state - self.device_table_view.device_config_in_sync_with_redis.emit(True) + self._set_busy_wrapper(enabled=False) + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None @SafeSlot() def _save_to_disk_action(self): @@ -613,8 +764,7 @@ def _update_device_to_table_from_dialog( ): if old_device_name and old_device_name != data.get("name", ""): self.device_table_view.remove_device(old_device_name) - self.device_table_view.update_device_configs([data], skip_validation=True) - self.device_table_view.update_device_validation(data, config_status, connection_status, msg) + self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name) @SafeSlot(dict, int, int, str, str) def _add_to_table_from_dialog( @@ -625,8 +775,15 @@ def _add_to_table_from_dialog( msg: str, old_device_name: str = "", ): - self.device_table_view.add_device_configs([data], skip_validation=True) - self.device_table_view.update_device_validation(data, config_status, connection_status, msg) + if connection_status == ConnectionStatus.UNKNOWN.value: + self.device_table_view.update_device_configs([data], skip_validation=False) + else: # Connection status was tested in dialog + # If device is connected, we remove it from the ophyd validation view + self.device_table_view.update_device_configs([data], skip_validation=True) + # Update validation status in device table view and ophyd validation view + self.ophyd_test_view._on_device_test_completed( + data, config_status, connection_status, msg + ) @SafeSlot() def _remove_device_action(self): diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d5d36267b..eef2ac49a 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -36,6 +36,7 @@ class _WidgetsEnumType(str, enum.Enum): "DarkModeButton": "DarkModeButton", "DeviceBrowser": "DeviceBrowser", "DeviceComboBox": "DeviceComboBox", + "DeviceInitializationProgressBar": "DeviceInitializationProgressBar", "DeviceLineEdit": "DeviceLineEdit", "Heatmap": "Heatmap", "Image": "Image", @@ -1071,6 +1072,79 @@ def devices(self) -> "list[str]": """ +class DeviceInitializationProgressBar(RPCBase): + """A progress bar that displays the progress of device initialization.""" + + @rpc_call + def set_value(self, value): + """ + Set the value of the progress bar. + + Args: + value (float): The value to set. + """ + + @rpc_call + def set_maximum(self, maximum: float): + """ + Set the maximum value of the progress bar. + + Args: + maximum (float): The maximum value. + """ + + @rpc_call + def set_minimum(self, minimum: float): + """ + Set the minimum value of the progress bar. + + Args: + minimum (float): The minimum value. + """ + + @property + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + @label_template.setter + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + @property + @rpc_call + def state(self): + """ + None + """ + + @state.setter + @rpc_call + def state(self): + """ + None + """ + + @rpc_call + def _get_label(self) -> str: + """ + Return the label text. mostly used for testing rpc. + """ + + class DeviceInputBase(RPCBase): """Mixin base class for device input widgets.""" diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 5b6bb392e..79553a29c 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -6,8 +6,8 @@ import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt -from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QApplication, QFileDialog, QWidget +from qtpy.QtGui import QFont, QPixmap +from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -15,8 +15,10 @@ from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils.busy_loader import BusyLoaderOverlay from bec_widgets.widgets.containers.dock import BECDock logger = bec_logger.logger @@ -38,7 +40,6 @@ def __init__( gui_id: str | None = None, theme_update: bool = False, start_busy: bool = False, - busy_text: str = "Loading…", **kwargs, ): """ @@ -65,18 +66,14 @@ def __init__( self._connect_to_theme_change() # Initialize optional busy loader overlay utility (lazy by default) - self._busy_overlay = None + self._busy_overlay: "BusyLoaderOverlay" | None = None + self._busy_state_widget: QWidget | None = None + self._loading = False if start_busy and isinstance(self, QWidget): - try: - overlay = self._ensure_busy_overlay(busy_text=busy_text) - if overlay is not None: - overlay.setGeometry(self.rect()) - overlay.raise_() - overlay.show() - self._loading = True - except Exception as exc: - logger.debug(f"Busy loader init skipped: {exc}") + self._busy_overlay = self._install_busy_loader() + self._adjust_busy_overlay() + self._loading = True def _connect_to_theme_change(self): """Connect to the theme change signal.""" @@ -97,8 +94,70 @@ def _update_theme(self, theme: str | None = None): self._update_overlay_theme(theme) self.apply_theme(theme) - def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"): - """Create the busy overlay on demand and cache it in _busy_overlay. + def create_busy_state_widget(self) -> QWidget: + """ + Method to create a custom busy state widget to be shown in the busy overlay. + Child classes should overrid this method to provide a custom widget if desired. + + Returns: + QWidget: The custom busy state widget. + + NOTE: + The implementation here is a SpinnerWidget with a "Loading..." label. This is the default + busy state widget for all BECWidgets. However, child classes with specific needs for the + busy state can easily overrite this method to provide a custom widget. The signature of + the method must be preserved to ensure compatibility with the busy overlay system. If + the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up. + + The widget may connect to the _busy_overlay signals foreground_color_changed and + scrim_color_changed to update its colors when the theme changes. + """ + + # Widget + class BusyStateWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # label + label = QLabel("Loading...", self) + label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + f = QFont(label.font()) + f.setBold(True) + f.setPointSize(f.pointSize() + 1) + label.setFont(f) + + # spinner + spinner = SpinnerWidget(self) + spinner.setFixedSize(42, 42) + + # Layout + lay = QVBoxLayout(self) + lay.setContentsMargins(24, 24, 24, 24) + lay.setSpacing(10) + lay.addStretch(1) + lay.addWidget(spinner, 0, Qt.AlignHCenter) + lay.addWidget(label, 0, Qt.AlignHCenter) + lay.addStretch(1) + self.setLayout(lay) + + def showEvent(self, event): + """Show event to start the spinner.""" + super().showEvent(event) + for child in self.findChildren(SpinnerWidget): + child.start() + + def hideEvent(self, event): + """Hide event to stop the spinner.""" + super().hideEvent(event) + for child in self.findChildren(SpinnerWidget): + child.stop() + + widget = BusyStateWidget(self) + + return widget + + def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: + """ + Create the busy overlay on demand and cache it in _busy_overlay. Returns the overlay instance or None if not a QWidget. """ if not isinstance(self, QWidget): @@ -107,38 +166,39 @@ def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"): if overlay is None: from bec_widgets.utils.busy_loader import install_busy_loader - overlay = install_busy_loader(self, text=busy_text, start_loading=False) + overlay = install_busy_loader(self, start_loading=False) self._busy_overlay = overlay + + # Create and set the busy state widget + self._busy_state_widget = self.create_busy_state_widget() + self._busy_overlay.set_widget(self._busy_state_widget) return overlay - def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None: + def _adjust_busy_overlay(self) -> None: """Create and attach the loading overlay to this widget if QWidget is present.""" if not isinstance(self, QWidget): return - self._ensure_busy_overlay(busy_text=busy_text) - if start_busy and self._busy_overlay is not None: - self._busy_overlay.setGeometry(self.rect()) + if self._busy_overlay is not None: + self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member self._busy_overlay.raise_() self._busy_overlay.show() - def set_busy(self, enabled: bool, text: str | None = None) -> None: + def set_busy(self, enabled: bool) -> None: """ - Enable/disable the loading overlay. Optionally update the text. + Set the busy state of the widget. This will show or hide the loading overlay, which will + block user interaction with the widget and show the busy_state_widget if provided. Per + default, the busy state widget is a spinner with "Loading..." text. Args: - enabled(bool): Whether to enable the loading overlay. - text(str, optional): The text to display on the overlay. If None, the text is not changed. + enabled(bool): Whether to enable the busy state. """ if not isinstance(self, QWidget): return - if getattr(self, "_busy_overlay", None) is None: - self._ensure_busy_overlay(busy_text=text or "Loading…") - if text is not None: - self.set_busy_text(text) + # If not yet installed, install the busy overlay now together with the busy state widget + if self._busy_overlay is None: + self._busy_overlay = self._install_busy_loader() if enabled: - self._busy_overlay.setGeometry(self.rect()) - self._busy_overlay.raise_() - self._busy_overlay.show() + self._adjust_busy_overlay() else: self._busy_overlay.hide() self._loading = bool(enabled) @@ -152,19 +212,6 @@ def is_busy(self) -> bool: """ return bool(getattr(self, "_loading", False)) - def set_busy_text(self, text: str) -> None: - """ - Update the text on the loading overlay. - - Args: - text(str): The text to display on the overlay. - """ - overlay = getattr(self, "_busy_overlay", None) - if overlay is None: - overlay = self._ensure_busy_overlay(busy_text=text) - if overlay is not None: - overlay.set_text(text) - @SafeSlot(str) def apply_theme(self, theme: str): """ @@ -177,8 +224,8 @@ def apply_theme(self, theme: str): def _update_overlay_theme(self, theme: str): try: overlay = getattr(self, "_busy_overlay", None) - if overlay is not None and hasattr(overlay, "update_palette"): - overlay.update_palette() + if overlay is not None: + overlay._update_palette() except Exception: logger.warning(f"Failed to apply theme {theme} to {self}") @@ -304,10 +351,13 @@ def cleanup(self): self.removeEventFilter(filt) except Exception as exc: logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + + # Cleanup the overlay widget. This will call cleanup on the custom widget if present. + + overlay.cleanup() overlay.deleteLater() except Exception as exc: logger.warning(f"Failed to delete busy overlay: {exc}") - self._busy_overlay = None def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index 2305170e4..76af94d33 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -1,7 +1,7 @@ from __future__ import annotations -from qtpy.QtCore import QEvent, QObject, Qt, QTimer -from qtpy.QtGui import QColor, QFont +from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal +from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QApplication, QFrame, @@ -15,8 +15,8 @@ from bec_widgets import BECWidget from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.plots.waveform.waveform import Waveform -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget class _OverlayEventFilter(QObject): @@ -53,132 +53,200 @@ class BusyLoaderOverlay(QWidget): BusyLoaderOverlay: The overlay instance. """ - def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs): + foreground_color_changed = Signal(QColor) + scrim_color_changed = Signal(QColor) + + def __init__(self, parent: QWidget, opacity: float = 0.85, **kwargs): super().__init__(parent=parent, **kwargs) + self.setAttribute(Qt.WA_StyledBackground, True) self.setAutoFillBackground(False) self.setAttribute(Qt.WA_TranslucentBackground, True) self._opacity = opacity + self._scrim_color = QColor(0, 0, 0, 110) + self._label_color = QColor(240, 240, 240) + self._filter: QObject | None = None - self._label = QLabel(text, self) - self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - f = QFont(self._label.font()) - f.setBold(True) - f.setPointSize(f.pointSize() + 1) - self._label.setFont(f) - - self._spinner = SpinnerWidget(self) - self._spinner.setFixedSize(42, 42) + # Set Main Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(10) + self.setLayout(layout) - lay = QVBoxLayout(self) - lay.setContentsMargins(24, 24, 24, 24) - lay.setSpacing(10) - lay.addStretch(1) - lay.addWidget(self._spinner, 0, Qt.AlignHCenter) - lay.addWidget(self._label, 0, Qt.AlignHCenter) - lay.addStretch(1) + # Custom widget placeholder + self._custom_widget: QWidget | None = None + # Add a frame around the content self._frame = QFrame(self) self._frame.setObjectName("busyFrame") self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True) self._frame.lower() # Defaults - self._scrim_color = QColor(0, 0, 0, 110) - self._label_color = QColor(240, 240, 240) - self.update_palette() + self._update_palette() # Start hidden; interactions beneath are blocked while visible self.hide() - # --- API --- - def set_text(self, text: str): + @SafeProperty(QColor, notify=scrim_color_changed) + def scrim_color(self) -> QColor: """ - Update the overlay text. + The overlay scrim color. + """ + return self._scrim_color + + @scrim_color.setter + def scrim_color(self, value: QColor): + if not isinstance(value, QColor): + raise TypeError("scrim_color must be a QColor") + self._scrim_color = value + self.update() + + @SafeProperty(QColor, notify=foreground_color_changed) + def foreground_color(self) -> QColor: + """ + The overlay foreground color (text, spinner). + """ + return self._label_color + + @foreground_color.setter + def foreground_color(self, value: QColor): + if not isinstance(value, QColor): + try: + color = QColor(value) + if not color.isValid(): + raise ValueError(f"Invalid color: {value}") + except Exception: + # pylint: disable=raise-missing-from + raise ValueError(f"Color {value} is invalid, cannot be converted to QColor") + self._label_color = value + self.update() + + def set_filter(self, filt: _OverlayEventFilter): + """ + Set an event filter to keep the overlay sized and stacked over its target. + + Args: + filt(QObject): The event filter instance. + """ + self._filter = filt + target = filt._target + if self.parent() != target: + logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}") + target.installEventFilter(self._filter) + + ###################### + ### Public methods ### + ###################### + + def set_widget(self, widget: QWidget): + """ + Set a custom widget as an overlay for the busy overlay. Args: - text(str): The text to display on the overlay. + widget(QWidget): The custom widget to display. """ - self._label.setText(text) + lay = self.layout() + if lay is None: + return + self._custom_widget = widget + lay.addWidget(widget, 0, Qt.AlignHCenter) def set_opacity(self, opacity: float): """ - Set overlay opacity (0..1). + Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a + value outside this range is provided, it will be clamped. Args: opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque). """ self._opacity = max(0.0, min(1.0, float(opacity))) # Re-apply alpha using the current theme color - if isinstance(self._scrim_color, QColor): - base = QColor(self._scrim_color) - base.setAlpha(int(255 * self._opacity)) - self._scrim_color = base + base = self.scrim_color + base.setAlpha(int(255 * self._opacity)) + self.scrim_color = base self.update() - def update_palette(self): + ########################## + ### Internal methods ### + ########################## + + def _update_palette(self): """ Update colors from the current application theme. """ - app = QApplication.instance() - if hasattr(app, "theme"): - theme = app.theme # type: ignore[attr-defined] - self._bg = theme.color("BORDER") - self._fg = theme.color("FG") - self._primary = theme.color("PRIMARY") + _app = QApplication.instance() + if hasattr(_app, "theme"): + theme = _app.theme # type: ignore[attr-defined] + _bg = theme.color("BORDER") + _fg = theme.color("FG") else: # Fallback neutrals - self._bg = QColor(30, 30, 30) - self._fg = QColor(230, 230, 230) + _bg = QColor(30, 30, 30) + _fg = QColor(230, 230, 230) + # Semi-transparent scrim derived from bg - self._scrim_color = QColor(self._bg) - self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) - self._spinner.update() - fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg) - self._label.setStyleSheet(f"color: {fg_hex};") + base = _bg if isinstance(_bg, QColor) else QColor(str(_bg)) + base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) + self.scrim_color = base + fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg)) + self.foreground_color = fg + + # Set the frame style with updated foreground colors self._frame.setStyleSheet( - f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" ) self.update() - # --- QWidget overrides --- + ############################# + ### Custom Event Handlers ### + ############################# + def showEvent(self, e): - self._spinner.start() + # Call showEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.showEvent(e) super().showEvent(e) def hideEvent(self, e): - self._spinner.stop() + # Call hideEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.hideEvent(e) super().hideEvent(e) def resizeEvent(self, e): + # Call resizeEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.resizeEvent(e) super().resizeEvent(e) r = self.rect().adjusted(10, 10, -10, -10) self._frame.setGeometry(r) - def paintEvent(self, e): - super().paintEvent(e) + # TODO should we have this cleanup here? + def cleanup(self): + """Cleanup resources used by the overlay.""" + if self._custom_widget is not None: + if hasattr(self._custom_widget, "cleanup"): + self._custom_widget.cleanup() def install_busy_loader( - target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35 + target: QWidget, start_loading: bool = False, opacity: float = 0.35 ) -> BusyLoaderOverlay: """ Attach a BusyLoaderOverlay to `target` and keep it sized and stacked. Args: target(QWidget): The widget to overlay. - text(str): Initial text to display. start_loading(bool): If True, show the overlay immediately. opacity(float): Overlay opacity (0..1). Returns: BusyLoaderOverlay: The overlay instance. """ - overlay = BusyLoaderOverlay(target, text=text, opacity=opacity) + overlay = BusyLoaderOverlay(parent=target, opacity=opacity) overlay.setGeometry(target.rect()) - filt = _OverlayEventFilter(target, overlay) - overlay._filter = filt # type: ignore[attr-defined] - target.installEventFilter(filt) + overlay.set_filter(_OverlayEventFilter(target, overlay)) if start_loading: overlay.show() return overlay @@ -188,10 +256,8 @@ def install_busy_loader( # Launchable demo # -------------------------- class DemoWidget(BECWidget, QWidget): # pragma: no cover - def __init__(self, parent=None): - super().__init__( - parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…" - ) + def __init__(self, parent=None, start_busy: bool = False): + super().__init__(parent=parent, theme_update=True, start_busy=start_busy) self._title = QLabel("Demo Content", self) self._title.setAlignment(Qt.AlignCenter) @@ -214,15 +280,14 @@ def __init__(self): super().__init__() self.setWindowTitle("Busy Loader — BECWidget demo") - left = DemoWidget() + left = DemoWidget(start_busy=True) right = DemoWidget() btn_on = QPushButton("Right → Loading") btn_off = QPushButton("Right → Ready") btn_text = QPushButton("Set custom text") - btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…")) + btn_on.clicked.connect(lambda: right.set_busy(True)) btn_off.clicked.connect(lambda: right.set_busy(False)) - btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…")) panel = QWidget() prow = QVBoxLayout(panel) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index 7f2edb328..a7b716cfa 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -5,10 +5,12 @@ from __future__ import annotations +import traceback from copy import deepcopy -from typing import Any, Callable, Iterable, Tuple +from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.callback_handler import EventType from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets @@ -26,6 +28,9 @@ get_validation_icons, ) +if TYPE_CHECKING: # pragma: no cover + from bec_lib.messages import ConfigAction + logger = bec_logger.logger _DeviceCfgIter = Iterable[dict[str, Any]] @@ -208,6 +213,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): # Signal emitted when the device config is in sync with Redis device_config_in_sync_with_redis = QtCore.Signal(bool) + # Request multiple validation updates for devices + request_update_multiple_device_validations = QtCore.Signal(list) + # Request update after client DEVICE_UPDATE event + request_update_after_client_device_update = QtCore.Signal() + _auto_size_request = QtCore.Signal() def __init__(self, parent: QtWidgets.QWidget | None = None, client=None): @@ -267,15 +277,66 @@ def __init__(self, parent: QtWidgets.QWidget | None = None, client=None): # Connect slots self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) + self.request_update_multiple_device_validations.connect( + self.update_multiple_device_validations + ) + self.request_update_after_client_device_update.connect(self._on_device_config_update) # Install event filter self.table.installEventFilter(self) + # Add hook to BECClient for DeviceUpdates + self.client_callback_id = self.client.callbacks.register( + event_type=EventType.DEVICE_UPDATE, callback=self.__on_client_device_update_event + ) + def cleanup(self): """Cleanup resources.""" self.row_data.clear() # Drop references to row data.. - # self._autosize_timer.stop() + self.client.callbacks.remove(self.client_callback_id) # Unregister callback super().cleanup() + def __on_client_device_update_event( + self, action: "ConfigAction", config: dict[str, dict[str, Any]] + ) -> None: + """Handle DEVICE_UPDATE events from the BECClient.""" + self.request_update_after_client_device_update.emit() + + @SafeSlot() + def _on_device_config_update(self) -> None: + """Handle device configuration updates from the BECClient.""" + # Determine the overlapping device configs between Redis and the table + device_config_overlap_with_bec = self._get_overlapping_configs() + if len(device_config_overlap_with_bec) > 0: + # Notify any listeners about the update, the device manager devices will now be up to date + self.device_configs_changed.emit(device_config_overlap_with_bec, True, True) + + # Correct all connection statuses in the table which are ConnectionStatus.CONNECTED + # to ConnectionStatus.CAN_CONNECT + device_status_updates = [] + validation_results = self.get_validation_results() + for device_name, (cfg, config_status, connection_status) in validation_results.items(): + if device_name is None: + continue + # Check if config is not in the overlap, but connection status is CONNECTED + # Update to CAN_CONNECT + if cfg not in device_config_overlap_with_bec: + if connection_status == ConnectionStatus.CONNECTED.value: + device_status_updates.append( + (cfg, config_status, ConnectionStatus.CAN_CONNECT.value, "") + ) + # Update only if there are any updates + if len(device_status_updates) > 0: + # NOTE We need to emit here a signal to call update_multiple_device_validations + # as this otherwise can cause problems with being executed from a python callback + # thread which are not properly scheduled in the Qt event loop. We see that this + # has caused issues in form of segfaults under certain usage of the UI. Please + # do not remove this signal & slot mechanism! + self.request_update_multiple_device_validations.emit(device_status_updates) + + # Check if in sync with BEC server session + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + # ------------------------------------------------------------------------- # Custom hooks for table events # ------------------------------------------------------------------------- @@ -769,6 +830,51 @@ def _is_config_in_sync_with_redis(self): logger.error(f"Error comparing device configs: {e}") return False + def _get_overlapping_configs(self) -> list[dict[str, Any]]: + """ + Get the device configs that overlap between the table and the config in the current running BEC session. + A device will be ignored if it is disabled in the BEC session. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to check. + + Returns: + list[dict[str, Any]]: The list of overlapping device configs. + """ + overlapping_configs = [] + for cfg in self.get_device_config(): + device_name = cfg.get("name", None) + if device_name is None: + continue + if self._is_device_in_redis_session(device_name, cfg): + overlapping_configs.append(cfg) + + return overlapping_configs + + def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool: + """Check if a device is in the running section.""" + dev_obj = self.client.device_manager.devices.get(device_name, None) + if dev_obj is None or dev_obj.enabled is False: + return False + return self._compare_device_configs(dev_obj._config, device_config) + + def _compare_device_configs(self, config1: dict, config2: dict) -> bool: + """Compare two device configurations through the Device model in bec_lib.atlas_models. + + Args: + config1 (dict): The first device configuration. + config2 (dict): The second device configuration. + + Returns: + bool: True if the configurations are equivalent, False otherwise. + """ + try: + model1 = DeviceModel.model_validate(config1) + model2 = DeviceModel.model_validate(config2) + return model1 == model2 + except Exception: + return False + # ------------------------------------------------------------------------- # Public API to manage device configs in the table # ------------------------------------------------------------------------- @@ -832,7 +938,7 @@ def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: boo device_configs (Iterable[dict[str, Any]]): The device configs to set. skip_validation (bool): Whether to skip validation for the set devices. """ - self.set_busy(True, text="Loading device configurations...") + self.set_busy(True) with self.table_sort_on_hold: self.clear_device_configs() cfgs_added = [] @@ -842,12 +948,12 @@ def set_device_config(self, device_configs: _DeviceCfgIter, skip_validation: boo self.device_configs_changed.emit(cfgs_added, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot() def clear_device_configs(self): - """Clear the device configs. Skips validation per default.""" - self.set_busy(True, text="Clearing device configurations...") + """Clear the device configs. Skips validation by default.""" + self.set_busy(True) device_configs = self.get_device_config() with self.table_sort_on_hold: self._clear_table() @@ -856,7 +962,7 @@ def clear_device_configs(self): ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list, bool) def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): @@ -869,7 +975,7 @@ def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bo device_configs (Iterable[dict[str, Any]]): The device configs to add. skip_validation (bool): Whether to skip validation for the added devices. """ - self.set_busy(True, text="Adding device configurations...") + self.set_busy(True) already_in_table = [] not_in_table = [] with self.table_sort_on_hold: @@ -894,7 +1000,7 @@ def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bo self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list, bool) def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): @@ -905,7 +1011,7 @@ def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: device_configs (Iterable[dict[str, Any]]): The device configs to update. skip_validation (bool): Whether to skip validation for the updated devices. """ - self.set_busy(True, text="Loading device configurations...") + self.set_busy(True) cfgs_updated = [] with self.table_sort_on_hold: for cfg in device_configs: @@ -920,7 +1026,7 @@ def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: self.device_configs_changed.emit(cfgs_updated, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list) def remove_device_configs(self, device_configs: _DeviceCfgIter): @@ -930,7 +1036,7 @@ def remove_device_configs(self, device_configs: _DeviceCfgIter): Args: device_configs (dict[str, dict]): The device configs to remove. """ - self.set_busy(True, text="Removing device configurations...") + self.set_busy(True) cfgs_to_be_removed = list(device_configs) with self.table_sort_on_hold: self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed]) @@ -939,7 +1045,7 @@ def remove_device_configs(self, device_configs: _DeviceCfgIter): ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(str) def remove_device(self, device_name: str): @@ -949,11 +1055,11 @@ def remove_device(self, device_name: str): Args: device_name (str): The name of the device to remove. """ - self.set_busy(True, text=f"Removing device configuration for {device_name}...") + self.set_busy(True) row_data = self.row_data.get(device_name) if not row_data: logger.warning(f"Device {device_name} not found in table for removal.") - self.set_busy(False, text="") + self.set_busy(False) return with self.table_sort_on_hold: self._remove_rows_by_name([row_data.data["name"]]) @@ -961,7 +1067,7 @@ def remove_device(self, device_name: str): self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list) def update_multiple_device_validations(self, validation_results: _ValidationResultIter): @@ -973,9 +1079,15 @@ def update_multiple_device_validations(self, validation_results: _ValidationResu Args: device_configs (Iterable[dict[str, Any]]): The device configs to update. """ - self.set_busy(True, text="Updating device validations in session...") + self.set_busy(True) self.table.setSortingEnabled(False) + logger.info( + f"Updating multiple device validation statuses with names {[cfg.get('name', '') for cfg, _, _, _ in validation_results]}..." + ) for cfg, config_status, connection_status, _ in validation_results: + logger.info( + f"Updating device {cfg.get('name', '')} with config status {config_status} and connection status {connection_status}..." + ) row = self._find_row_by_name(cfg.get("name", "")) if row is None: logger.warning(f"Device {cfg.get('name')} not found in table for session update.") @@ -984,7 +1096,7 @@ def update_multiple_device_validations(self, validation_results: _ValidationResu in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.table.setSortingEnabled(True) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(dict, int, int, str) def update_device_validation( @@ -997,13 +1109,13 @@ def update_device_validation( Args: """ - self.set_busy(True, text="Updating device validation status...") + self.set_busy(True) row = self._find_row_by_name(device_config.get("name", "")) if row is None: logger.warning( f"Device {device_config.get('name')} not found in table for validation update." ) - self.set_busy(False, text="") + self.set_busy(False) return # Disable here sorting without context manager to avoid triggering of registered # resizing methods. Those can be quite heavy, thus, should not run on every @@ -1013,4 +1125,4 @@ def update_device_validation( self.table.setSortingEnabled(True) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index 5ff9d978b..103958797 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -548,9 +548,10 @@ def change_device_configs( if device_name is None: # Config missing name, will be skipped.. logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.") continue - if not added or skip_validation is True: # Remove requested + if not added: # Remove requested, holds priority over skip_validation self._remove_device_config(cfg) continue + # Check if device is already in running session with the same config if self._is_device_in_redis_session(cfg.get("name"), cfg): logger.debug( f"Device {device_name} already in running session with same config. Skipping." @@ -563,29 +564,39 @@ def change_device_configs( "Device already in session.", ) ) + # If in addition, the device is to be kept visible after validation, we ensure it is added + # and potentially update it's config & validation icons if device_name in self._keep_visible_after_validation: - self._add_device_config( - cfg, - connect=connect, - force_connect=force_connect, - timeout=timeout, - skip_validation=True, - ) - self._on_device_test_completed( - cfg, - ConfigStatus.VALID.value, - ConnectionStatus.CONNECTED.value, - "Device already in session.", - ) - self._remove_device_config(cfg) + if not self._device_already_exists(device_name): + self._add_device_config( + cfg, + connect=connect, + force_connect=force_connect, + timeout=timeout, + skip_validation=True, + ) + # Now make sure that the existing widget is updated to reflect the CONNECTED & VALID status + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + self._on_device_test_completed( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) + else: # If not to be kept visible, we ensure it is removed from the list + self._remove_device_config(cfg) + continue # Now we continue to the next device config + if skip_validation is True: # Skip validation requested, so we skip this continue - if not self._device_already_exists(cfg.get("name")): # New device case + # New device case, that is not in BEC session + if not self._device_already_exists(cfg.get("name")): self._add_device_config( cfg, connect=connect, force_connect=force_connect, timeout=timeout ) else: # Update existing, but removing first logger.info(f"Device {cfg.get('name')} already exists, re-adding it.") - self._remove_device_config(cfg) + self._remove_device_config(cfg, force_remove=True) self._add_device_config( cfg, connect=connect, force_connect=force_connect, timeout=timeout ) @@ -661,13 +672,13 @@ def _add_device_config( if not skip_validation: self.__delayed_submit_test(widget, connect, force_connect, timeout) - def _remove_device(self, device_name: str) -> None: + def _remove_device(self, device_name: str, force_remove: bool = False) -> None: if not self._device_already_exists(device_name): logger.debug( f"Device with name {device_name} not found in OphydValidation, can't remove it." ) return - if device_name in self._keep_visible_after_validation: + if device_name in self._keep_visible_after_validation and not force_remove: logger.debug( f"Device with name {device_name} is set to be kept visible after validation, not removing it." ) @@ -676,9 +687,11 @@ def _remove_device(self, device_name: str) -> None: self.thread_pool_manager.clear_device_in_queue(device_name) self.list_widget.remove_widget_item(device_name) - def _remove_device_config(self, device_config: dict[str, Any]) -> None: + def _remove_device_config( + self, device_config: dict[str, Any], force_remove: bool = False + ) -> None: device_name = device_config.get("name") - self._remove_device(device_name) + self._remove_device(device_name, force_remove=force_remove) @SafeSlot(str, dict, bool, bool, float) def _on_request_rerun_validation( diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py index 093aad717..de18bbeb5 100644 --- a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -12,9 +12,8 @@ class DeviceInitializationProgressBar(BECProgressBar): # Signal emitted for failed device initializations failed_devices_changed = Signal(list) - def __init__(self, parent=None, client=None): - super().__init__(parent=parent, client=client) - self._latest_device_config_msg: dict | None = None + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) self._failed_devices: list[str] = [] self.bec_dispatcher.connect_slot( slot=self._update_device_initialization_progress, diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject new file mode 100644 index 000000000..2f908ecbe --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject @@ -0,0 +1 @@ +{'files': ['device_initialization_progress_bar.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py new file mode 100644 index 000000000..52ceea461 --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py @@ -0,0 +1,59 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) + +DOM_XML = """ + + + + +""" + + +class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = DeviceInitializationProgressBar(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME) + + def includeFile(self): + return "device_initialization_progress_bar" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "DeviceInitializationProgressBar" + + def toolTip(self): + return "A progress bar that displays the progress of device initialization." + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py new file mode 100644 index 000000000..1f60596ce --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py @@ -0,0 +1,17 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import ( + DeviceInitializationProgressBarPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index ca1d66f7a..990f030a8 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -38,6 +38,8 @@ def run(self): self._process( {"action": self.action, "config": self.config, "wait_for_response": False} ) + elif self.action == "cancel": + self._process_cancel() elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( @@ -73,6 +75,13 @@ def _process(self, req_args: dict): self.config_helper.handle_update_reply(reply, RID, timeout) logger.info("Done updating config!") + def _process_cancel(self): + logger.info("Cancelling ongoing configuration operation") + self.config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Done cancelling configuration operation") + def process_remove_readd(self, dev_name: str): logger.info(f"Removing and readding device: {dev_name}") self.process_simple_action(dev_name, "remove") diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py index 2f9e859c2..466ea03a1 100644 --- a/tests/unit_tests/test_busy_loader.py +++ b/tests/unit_tests/test_busy_loader.py @@ -2,27 +2,16 @@ from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget from bec_widgets import BECWidget +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget from .client_mocks import mocked_client class _TestBusyWidget(BECWidget, QWidget): def __init__( - self, - parent=None, - *, - start_busy: bool = False, - busy_text: str = "Loading…", - theme_update: bool = False, - **kwargs, + self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs ): - super().__init__( - parent=parent, - theme_update=theme_update, - start_busy=start_busy, - busy_text=busy_text, - **kwargs, - ) + super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(QLabel("content", self)) @@ -30,7 +19,7 @@ def __init__( @pytest.fixture def widget_busy(qtbot, mocked_client): - w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…") + w = _TestBusyWidget(client=mocked_client, start_busy=True) qtbot.addWidget(w) w.resize(320, 200) w.show() @@ -59,12 +48,21 @@ def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): overlay = getattr(widget_idle, "_busy_overlay", None) assert overlay is None, "Overlay should be lazily created when idle" - widget_idle.set_busy(True, "Fetching data…") + widget_idle.set_busy(True) overlay = getattr(widget_idle, "_busy_overlay") qtbot.waitUntil(lambda: overlay.isVisible()) - lbl = getattr(overlay, "_label") - assert lbl.text() == "Fetching data…" + assert hasattr(widget_idle, "_busy_state_widget") + assert overlay._custom_widget is not None + + label = overlay._custom_widget.findChild(QLabel) + assert label is not None + assert label.text() == "Loading..." + + spinner = overlay._custom_widget.findChild(SpinnerWidget) + assert spinner is not None + assert spinner.isVisible() + assert spinner._started is True widget_idle.set_busy(False) qtbot.waitUntil(lambda: overlay.isHidden()) @@ -106,19 +104,6 @@ def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): assert "rgba(128, 128, 128, 110)" in ss -def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle): - overlay = getattr(widget_idle, "_busy_overlay", None) - assert overlay is None, "Overlay should be created on first text update" - - widget_idle.set_busy_text("Preparing…") - overlay = getattr(widget_idle, "_busy_overlay") - assert overlay is not None - assert overlay.isHidden() - - lbl = getattr(overlay, "_label") - assert lbl.text() == "Preparing…" - - def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): overlay = getattr(widget_busy, "_busy_overlay", None) assert overlay is not None, "Busy overlay should exist on a start_busy widget" @@ -131,15 +116,11 @@ def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): qtbot.waitUntil(lambda: overlay.isHidden()) # Switch ON again (with new text) - widget_busy.set_busy(True, "Back to work…") + widget_busy.set_busy(True) qtbot.waitUntil(lambda: overlay.isVisible()) # Same overlay instance reused (no duplication) assert getattr(widget_busy, "_busy_overlay") is overlay - # Label updated - lbl = getattr(overlay, "_label") - assert lbl.text() == "Back to work…" - # Geometry follows parent after re-show qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py index 9904d9196..22aac8246 100644 --- a/tests/unit_tests/test_device_initialization_progress_bar.py +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -1,4 +1,4 @@ -# pylint skip +# pylint skip-file import pytest from bec_lib.messages import DeviceInitializationProgressMessage @@ -6,10 +6,12 @@ DeviceInitializationProgressBar, ) +from .client_mocks import mocked_client + @pytest.fixture -def progress_bar(qtbot): - widget = DeviceInitializationProgressBar() +def progress_bar(qtbot, mocked_client): + widget = DeviceInitializationProgressBar(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index 80c31b6b5..1131929e1 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -361,6 +361,75 @@ def test_initialization(self, device_table: DeviceTable): assert device_table.search_input is not None assert device_table.fuzzy_is_disabled.isChecked() is False assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows + assert hasattr(device_table, "client_callback_id") + + def test_device_table_client_device_update_callback( + self, device_table: DeviceTable, mocked_client, qtbot + ): + """ + Test that runs the client device update callback. This should update the status of devices in the table + that are in sync with the client. + + I. First test will run a callback when no devices are in the table, should do nothing. + II. Second test will add devices all devices from the mocked_client, then remove one + device from the client and run the callback. The table should update the status of the + removed device to CAN_CONNECT and all others to CONNECTED. + """ + device_configs_changed_calls = [] + requested_update_for_multiple_device_validations = [] + + def _device_configs_changed_cb(cfgs: list[dict], added: bool, skip_validation: bool): + """Callback to capture device config changes.""" + device_configs_changed_calls.append((cfgs, added, skip_validation)) + + def _requested_update_for_multiple_device_validations_cb(device_names: list): + """Callback to capture requests for multiple device validations.""" + requested_update_for_multiple_device_validations.append(device_names) + + device_table.device_configs_changed.connect(_device_configs_changed_cb) + device_table.request_update_multiple_device_validations.connect( + _requested_update_for_multiple_device_validations_cb + ) + + # I. First test case with no devices in the table + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + assert not device_configs_changed_calls + assert not requested_update_for_multiple_device_validations + + # II. Second test case, add all devices from mocked client to table + # Add all devices from mocked client to table. + device_configs = mocked_client.device_manager._get_redis_device_config() + device_table.add_device_configs(device_configs, skip_validation=True) + mocked_client.device_manager.devices.pop("samx") # Remove samx from client + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + validation_results = { + cfg.get("name"): ( + DeviceModel.model_validate(cfg).model_dump(), + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + for cfg in device_configs + } + with mock.patch.object( + device_table, "get_validation_results", return_value=validation_results + ): + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + # One for add_device_configs, one for the update + assert len(device_configs_changed_calls) == 2 + # The first call should have one more device than the second + assert ( + len(device_configs_changed_calls[0][0]) + - len(device_configs_changed_calls[1][0]) + == 1 + ) + # Only one device should have been marked for validation update + assert len(requested_update_for_multiple_device_validations) == 1 + assert len(requested_update_for_multiple_device_validations[0]) == 1 def test_add_row(self, device_table: DeviceTable, sample_devices: dict): """Test adding a single device row.""" @@ -1060,9 +1129,7 @@ def test_ophyd_test_keep_visible_after_validation(self, ophyd_test: OphydValidat ophyd_test, "_is_device_in_redis_session", return_value=True ) as mock_is_device_in_redis_session, mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config, - mock.patch.object( - ophyd_test, "_on_device_test_completed" - ) as mock_on_device_test_completed, + mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget, ): ophyd_test.change_device_configs( [{"name": "device_2", "deviceClass": "TestClass"}], @@ -1070,12 +1137,7 @@ def test_ophyd_test_keep_visible_after_validation(self, ophyd_test: OphydValidat skip_validation=False, ) mock_add_device_config.assert_called_once() - mock_on_device_test_completed.assert_called_once_with( - {"name": "device_2", "deviceClass": "TestClass"}, - ConfigStatus.VALID.value, - ConnectionStatus.CONNECTED.value, - "Device already in session.", - ) + mock_get_widget.assert_called_once_with("device_2") def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): """Test adding devices to OphydValidation widget.""" diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 214855e2c..12aa2219a 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -305,7 +305,7 @@ def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot): qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) mock_warning_box.assert_called_once_with( "Invalid Device Name", - f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r}", ) mock_create_dialog.assert_not_called() mock_create_validation.assert_not_called() @@ -741,35 +741,3 @@ def test_run_validate_connection_action_connected( "rerun_validation" ].action.action.triggered.emit() assert len(mock_change_configs.call_args[0][0]) == 1 - - def test_update_validation_icons_after_upload( - self, - device_manager_display_widget: DeviceManagerDisplayWidget, - device_configs: list[dict[str, Any]], - ): - """Test that validation icons are updated after uploading to Redis.""" - dm_view = device_manager_display_widget - - # Add device configs to the table - dm_view.device_table_view.add_device_configs(device_configs) - # Update the device manager devices to match what's in the table - dm_view.client.device_manager.devices = {cfg["name"]: cfg for cfg in device_configs} - - # Simulate callback - dm_view._update_validation_icons_after_upload() - - # Get validation results from the table - validation_results = dm_view.device_table_view.get_validation_results() - # Check that all devices are connected and status is updated - for dev_name, (cfg, _, connection_status) in validation_results.items(): - assert cfg in device_configs - assert connection_status == ConnectionStatus.CONNECTED.value - - # Check that no devices are in ophyd_validation widget - # Those should be all cleared after upload - cfgs = dm_view.ophyd_test_view.get_device_configs() - assert len(cfgs) == 0 - - # Check that upload config button is disabled - action = dm_view.toolbar.components.get_action("update_config_redis") - assert action.action.isEnabled() is False From b1a3403cd3e3fdd0d5efe60f9e0576bf7e9f6041 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 16:38:12 +0100 Subject: [PATCH 109/155] fix(busy-loader): adjust busy loader and tests --- .../device_manager_display_widget.py | 1 + bec_widgets/utils/bec_widget.py | 11 +- bec_widgets/utils/busy_loader.py | 118 +++++++++--------- tests/unit_tests/test_busy_loader.py | 19 +++ tests/unit_tests/test_device_manager_view.py | 9 ++ 5 files changed, 93 insertions(+), 65 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index d90e72d06..3ae9c1bb7 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -248,6 +248,7 @@ def _about_to_quit_handler(self): def _set_busy_wrapper(self, enabled: bool): """Thin wrapper around set_busy to flip the state variable.""" + self._busy_overlay.set_opacity(0.8) self._config_upload_active = enabled self.set_busy(enabled=enabled) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 79553a29c..bb48b5271 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -12,6 +12,7 @@ import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig +from bec_widgets.utils.busy_loader import install_busy_loader from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy @@ -70,9 +71,9 @@ def __init__( self._busy_state_widget: QWidget | None = None self._loading = False + self._busy_overlay = self._install_busy_loader() if start_busy and isinstance(self, QWidget): - self._busy_overlay = self._install_busy_loader() - self._adjust_busy_overlay() + self._show_busy_overlay() self._loading = True def _connect_to_theme_change(self): @@ -152,7 +153,6 @@ def hideEvent(self, event): child.stop() widget = BusyStateWidget(self) - return widget def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: @@ -164,7 +164,6 @@ def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: return None overlay = getattr(self, "_busy_overlay", None) if overlay is None: - from bec_widgets.utils.busy_loader import install_busy_loader overlay = install_busy_loader(self, start_loading=False) self._busy_overlay = overlay @@ -174,7 +173,7 @@ def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: self._busy_overlay.set_widget(self._busy_state_widget) return overlay - def _adjust_busy_overlay(self) -> None: + def _show_busy_overlay(self) -> None: """Create and attach the loading overlay to this widget if QWidget is present.""" if not isinstance(self, QWidget): return @@ -198,7 +197,7 @@ def set_busy(self, enabled: bool) -> None: if self._busy_overlay is None: self._busy_overlay = self._install_busy_loader() if enabled: - self._adjust_busy_overlay() + self._show_busy_overlay() else: self._busy_overlay.hide() self._loading = bool(enabled) diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index 76af94d33..4df122eff 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -13,10 +13,8 @@ QWidget, ) -from bec_widgets import BECWidget from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty -from bec_widgets.widgets.plots.waveform.waveform import Waveform class _OverlayEventFilter(QObject): @@ -56,14 +54,14 @@ class BusyLoaderOverlay(QWidget): foreground_color_changed = Signal(QColor) scrim_color_changed = Signal(QColor) - def __init__(self, parent: QWidget, opacity: float = 0.85, **kwargs): + def __init__(self, parent: QWidget, opacity: float = 0.35, **kwargs): super().__init__(parent=parent, **kwargs) self.setAttribute(Qt.WA_StyledBackground, True) self.setAutoFillBackground(False) self.setAttribute(Qt.WA_TranslucentBackground, True) self._opacity = opacity - self._scrim_color = QColor(0, 0, 0, 110) + self._scrim_color = QColor(128, 128, 128, 110) self._label_color = QColor(240, 240, 240) self._filter: QObject | None = None @@ -165,7 +163,7 @@ def set_opacity(self, opacity: float): base = self.scrim_color base.setAlpha(int(255 * self._opacity)) self.scrim_color = base - self.update() + self._update_palette() ########################## ### Internal methods ### @@ -193,8 +191,9 @@ def _update_palette(self): self.foreground_color = fg # Set the frame style with updated foreground colors + r, g, b, a = base.getRgb() self._frame.setStyleSheet( - f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}" ) self.update() @@ -255,62 +254,63 @@ def install_busy_loader( # -------------------------- # Launchable demo # -------------------------- -class DemoWidget(BECWidget, QWidget): # pragma: no cover - def __init__(self, parent=None, start_busy: bool = False): - super().__init__(parent=parent, theme_update=True, start_busy=start_busy) - - self._title = QLabel("Demo Content", self) - self._title.setAlignment(Qt.AlignCenter) - self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken) - lay = QVBoxLayout(self) - lay.addWidget(self._title) - waveform = Waveform(self) - waveform.plot([1, 2, 3, 4, 5]) - lay.addWidget(waveform, 1) - - QTimer.singleShot(5000, self._ready) - - def _ready(self): - self._title.setText("Ready ✓") - self.set_busy(False) - - -class DemoWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("Busy Loader — BECWidget demo") - - left = DemoWidget(start_busy=True) - right = DemoWidget() - - btn_on = QPushButton("Right → Loading") - btn_off = QPushButton("Right → Ready") - btn_text = QPushButton("Set custom text") - btn_on.clicked.connect(lambda: right.set_busy(True)) - btn_off.clicked.connect(lambda: right.set_busy(False)) - - panel = QWidget() - prow = QVBoxLayout(panel) - prow.addWidget(btn_on) - prow.addWidget(btn_off) - prow.addWidget(btn_text) - prow.addStretch(1) - - central = QWidget() - row = QHBoxLayout(central) - row.setContentsMargins(12, 12, 12, 12) - row.setSpacing(12) - row.addWidget(left, 1) - row.addWidget(right, 1) - row.addWidget(panel, 0) - - self.setCentralWidget(central) - self.resize(900, 420) - - if __name__ == "__main__": # pragma: no cover import sys + from bec_widgets.utils.bec_widget import BECWidget + from bec_widgets.widgets.plots.waveform.waveform import Waveform + + class DemoWidget(BECWidget, QWidget): # pragma: no cover + def __init__(self, parent=None, start_busy: bool = False): + super().__init__(parent=parent, theme_update=True, start_busy=start_busy) + + self._title = QLabel("Demo Content", self) + self._title.setAlignment(Qt.AlignCenter) + self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken) + lay = QVBoxLayout(self) + lay.addWidget(self._title) + waveform = Waveform(self) + waveform.plot([1, 2, 3, 4, 5]) + lay.addWidget(waveform, 1) + + QTimer.singleShot(5000, self._ready) + + def _ready(self): + self._title.setText("Ready ✓") + self.set_busy(False) + + class DemoWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Busy Loader — BECWidget demo") + + left = DemoWidget(start_busy=True) + right = DemoWidget() + + btn_on = QPushButton("Right → Loading") + btn_off = QPushButton("Right → Ready") + btn_text = QPushButton("Set custom text") + btn_on.clicked.connect(lambda: right.set_busy(True)) + btn_off.clicked.connect(lambda: right.set_busy(False)) + + panel = QWidget() + prow = QVBoxLayout(panel) + prow.addWidget(btn_on) + prow.addWidget(btn_off) + prow.addWidget(btn_text) + prow.addStretch(1) + + central = QWidget() + row = QHBoxLayout(central) + row.setContentsMargins(12, 12, 12, 12) + row.setSpacing(12) + row.addWidget(left, 1) + row.addWidget(right, 1) + row.addWidget(panel, 0) + + self.setCentralWidget(central) + self.resize(900, 420) + app = QApplication(sys.argv) apply_theme("light") w = DemoWindow() diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py index 466ea03a1..d7fdc68aa 100644 --- a/tests/unit_tests/test_busy_loader.py +++ b/tests/unit_tests/test_busy_loader.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget @@ -68,6 +69,24 @@ def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): qtbot.waitUntil(lambda: overlay.isHidden()) +def test_becwidget_busy_overlay_set_opacity(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Default opacity is 0.7 + frame = getattr(overlay, "_frame", None) + assert frame is not None + sheet = frame.styleSheet() + _, _, _, a = overlay.scrim_color.getRgb() + assert np.isclose(a / 255, 0.35, atol=0.02) + + # Change opacity + overlay.set_opacity(0.7) + qtbot.waitUntil(lambda: overlay.isVisible()) + _, _, _, a = overlay.scrim_color.getRgb() + assert np.isclose(a / 255, 0.7, atol=0.02) + + def test_becwidget_overlay_tracks_resize(qtbot, widget_busy): overlay = getattr(widget_busy, "_busy_overlay") qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 12aa2219a..0ca779fb5 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -23,6 +23,7 @@ ValidationSection, ) from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import ( + CustomBusyWidget, DeviceManagerDisplayWidget, ) from bec_widgets.applications.views.device_manager_view.device_manager_view import ( @@ -592,6 +593,14 @@ def device_manager_display_widget(self, qtbot, mocked_client): qtbot.waitExposed(widget) yield widget + @pytest.fixture + def custom_busy(self, qtbot, mocked_client): + """Fixture for the custom busy widget of the DeviceManagerDisplayWidget.""" + widget = CustomBusyWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + @pytest.fixture def device_configs(self, device_config: dict): """Fixture for multiple device configurations.""" From 2b27faf7795ee3f2ca5874e1dcc3be784a36d61e Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 17:23:36 +0100 Subject: [PATCH 110/155] fix(device-init-progress-bar): fix ui format for device init progressbar --- .../device_manager_display_widget.py | 17 +++- bec_widgets/cli/client.py | 62 +------------ .../device_initialization_progress_bar.py | 92 +++++++++++++------ 3 files changed, 86 insertions(+), 85 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 3ae9c1bb7..b2db6bbd3 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -13,6 +13,7 @@ from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme, material_icon from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal +from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QApplication, QFileDialog, @@ -77,12 +78,13 @@ def __init__(self, parent=None, client: BECClient | None = None): # Widgets progress = DeviceInitializationProgressBar(parent=self, client=client) + progress.setMinimumWidth(320) # Spinner spinner = SpinnerWidget(parent=self) scale = self._ui_scale() spinner_size = int(scale * 0.12) if scale else 1 - spinner_size = max(32, min(spinner_size, 64)) + spinner_size = max(32, min(spinner_size, 96)) spinner.setFixedSize(spinner_size, spinner_size) # Cancel button @@ -116,6 +118,17 @@ def __init__(self, parent=None, client: BECClient | None = None): content_layout.addStretch() content_layout.addWidget(cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) + if hasattr(color, "_colors"): + bg_color = color._colors.get("BG", None) + if bg_color is None: # Fallback if missing + bg_color = QColor(50, 50, 50, 255) + self.setStyleSheet( + f""" + background-color: {bg_color.name()}; + border-radius: 12px; + """ + ) + def _ui_scale(self) -> int: parent = self.parent() if not parent: @@ -248,7 +261,7 @@ def _about_to_quit_handler(self): def _set_busy_wrapper(self, enabled: bool): """Thin wrapper around set_busy to flip the state variable.""" - self._busy_overlay.set_opacity(0.8) + self._busy_overlay.set_opacity(0.92) self._config_upload_active = enabled self.set_busy(enabled=enabled) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index eef2ac49a..c0c0bdf7f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -36,7 +36,6 @@ class _WidgetsEnumType(str, enum.Enum): "DarkModeButton": "DarkModeButton", "DeviceBrowser": "DeviceBrowser", "DeviceComboBox": "DeviceComboBox", - "DeviceInitializationProgressBar": "DeviceInitializationProgressBar", "DeviceLineEdit": "DeviceLineEdit", "Heatmap": "Heatmap", "Image": "Image", @@ -1076,72 +1075,21 @@ class DeviceInitializationProgressBar(RPCBase): """A progress bar that displays the progress of device initialization.""" @rpc_call - def set_value(self, value): - """ - Set the value of the progress bar. - - Args: - value (float): The value to set. - """ - - @rpc_call - def set_maximum(self, maximum: float): - """ - Set the maximum value of the progress bar. - - Args: - maximum (float): The maximum value. - """ - - @rpc_call - def set_minimum(self, minimum: float): - """ - Set the minimum value of the progress bar. - - Args: - minimum (float): The minimum value. - """ - - @property - @rpc_call - def label_template(self): - """ - The template for the center label. Use $value, $maximum, and $percentage to insert the values. - - Examples: - >>> progressbar.label_template = "$value / $maximum - $percentage %" - >>> progressbar.label_template = "$value / $percentage %" - """ - - @label_template.setter - @rpc_call - def label_template(self): - """ - The template for the center label. Use $value, $maximum, and $percentage to insert the values. - - Examples: - >>> progressbar.label_template = "$value / $maximum - $percentage %" - >>> progressbar.label_template = "$value / $percentage %" - """ - - @property - @rpc_call - def state(self): + def remove(self): """ - None + Cleanup the BECConnector """ - @state.setter @rpc_call - def state(self): + def attach(self): """ None """ @rpc_call - def _get_label(self) -> str: + def detach(self): """ - Return the label text. mostly used for testing rpc. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py index de18bbeb5..07fe5ba37 100644 --- a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -1,12 +1,17 @@ +"""Module for a ProgressBar for device initialization progress.""" + from bec_lib.endpoints import MessageEndpoints from bec_lib.messages import DeviceInitializationProgressMessage from qtpy.QtCore import Signal +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar -class DeviceInitializationProgressBar(BECProgressBar): +class DeviceInitializationProgressBar(BECWidget, QWidget): """A progress bar that displays the progress of device initialization.""" # Signal emitted for failed device initializations @@ -15,12 +20,58 @@ class DeviceInitializationProgressBar(BECProgressBar): def __init__(self, parent=None, client=None, **kwargs): super().__init__(parent=parent, client=client, **kwargs) self._failed_devices: list[str] = [] + + # Main Layout with Group Box + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(0) + self.group_box = QGroupBox(self) + self.group_box.setTitle("Config Update Progress") + main_layout.addWidget(self.group_box) + lay = QVBoxLayout(self.group_box) + lay.setContentsMargins(25, 25, 25, 25) + lay.setSpacing(5) + + # Progress Bar and Label in Layout + self.progress_bar = BECProgressBar(parent=parent, client=client, **kwargs) + self.progress_bar.label_template = "$value / $maximum - $percentage %" + self.progress_label = QLabel("Initializing devices...", self) + + self.progress_label.setStyleSheet("font-size: 12px; font-weight: cursive;") + content_layout = QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(0) + content_layout.addWidget(self.progress_bar) + + # Layout for label, to place label properly below progress bar + # Adjust 10px left margin for aesthetic alignment + hor_layout = QHBoxLayout() + hor_layout.setContentsMargins(12, 0, 0, 0) + hor_layout.addWidget(self.progress_label) + content_layout.addLayout(hor_layout) + content_layout.addStretch() + + # Add content layout to main layout + lay.addLayout(content_layout) + self.bec_dispatcher.connect_slot( slot=self._update_device_initialization_progress, topics=MessageEndpoints.device_initialization_progress(), ) self._reset_progress_bar() + def _update_palette(self) -> None: + """Update theme palette for the widget.""" + _app = QApplication.instance() + if hasattr(_app, "theme"): + theme = _app.theme # type: ignore[attr-defined] + text_color = theme.color("FG") + else: + text_color = QColor(230, 230, 230) + self.progress_label.setStyleSheet( + f"color: {text_color.name()}; font-size: 12px; font-weight: cursive;" + ) + @SafeProperty(list) def failed_devices(self) -> list[str]: """Get the list of devices that failed to initialize. @@ -62,39 +113,26 @@ def _update_device_initialization_progress(self, msg: dict, metadata: dict) -> N msg: DeviceInitializationProgressMessage = ( DeviceInitializationProgressMessage.model_validate(msg) ) + # Reset progress bar if index has gone backwards, this indicates a new initialization sequence + old_value = self.progress_bar._user_value + if msg.index < old_value: + self._reset_progress_bar() + # Update progress based on message content if msg.finished is False: - self.label_template = "\n".join( - [ - f"Device initialization for '{msg.device}' is in progress...", - "$value / $maximum - $percentage %", - ] - ) + self.progress_label.setText(f"{msg.device} initialization in progress...") elif msg.finished is True and msg.success is False: self.add_failed_device(msg.device) - self.label_template = "\n".join( - [ - f"Device initialization for '{msg.device}' failed!", - "$value / $maximum - $percentage %", - ] - ) + self.progress_label.setText(f"{msg.device} initialization failed!") else: - self.label_template = "\n".join( - [ - f"Device initialization for '{msg.device}' succeeded!", - "$value / $maximum - $percentage %", - ] - ) - self.set_maximum(msg.total) - self.set_value(msg.index) + self.progress_label.setText(f"{msg.device} initialization succeeded!") + self.progress_bar.set_maximum(msg.total) + self.progress_bar.set_value(msg.index) self._update_tool_tip() def _reset_progress_bar(self) -> None: """Reset the progress bar to its initial state.""" - self.label_template = "\n".join( - ["Waiting for device initialization...", "$value / $maximum - $percentage %"] - ) - self.set_value(0) - self.set_maximum(1) + self.progress_bar.set_value(0) + self.progress_bar.set_maximum(100) self.reset_failed_devices() self._update_tool_tip() @@ -110,9 +148,11 @@ def _update_tool_tip(self) -> None: if __name__ == "__main__": # pragma: no cover import sys + from bec_qthemes import apply_theme from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + apply_theme("dark") progressBar = DeviceInitializationProgressBar() From 58e57169e8329bda98c1c533391e47e3b2804aed Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 17:40:00 +0100 Subject: [PATCH 111/155] test(config-communicator): add test for cancel action --- tests/unit_tests/test_config_communicator.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit_tests/test_config_communicator.py b/tests/unit_tests/test_config_communicator.py index 04e75bded..e4274565a 100644 --- a/tests/unit_tests/test_config_communicator.py +++ b/tests/unit_tests/test_config_communicator.py @@ -59,3 +59,13 @@ def test_remove_readd_with_device_config(qtbot): call(action="add", config=ANY, wait_for_response=False), ] ) + + +def test_cancel_config_action(qtbot): + ch = MagicMock(spec=ConfigHelper) + ch.send_config_request.return_value = "abcde" + cca = CommunicateConfigAction(config_helper=ch, device=None, config=None, action="cancel") + cca.run() + ch.send_config_request.assert_called_once_with( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) From 53fe1ac63d60252ecac6a3de10644c7dc83f6883 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 17:48:18 +0100 Subject: [PATCH 112/155] fix(device-manager-display-widget): fix error message popup on cancelling upload --- .../device_manager_display_widget.py | 27 +++++++------- tests/unit_tests/test_busy_loader.py | 3 +- ...test_device_initialization_progress_bar.py | 36 +++++++++---------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index b2db6bbd3..b414090db 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -231,8 +231,9 @@ def __init__(self, parent=None, *args, **kwargs): # Build dock layout using shared helpers self._build_docks() - logger.info("Connecting application about to quit signal to device manager view...") - QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler) + # TODO Implement once issue #1012 is solved + # logger.info("Connecting application about to quit signal to device manager view...") + # QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler) ############################## ### Custom set busy widget ### @@ -248,16 +249,17 @@ def create_busy_state_widget(self) -> QWidget: ### Application quit handler ### ################################ - @SafeSlot() - def _about_to_quit_handler(self): - """Handle application about to quit event. If config upload is active, cancel it.""" - logger.info("Application is quitting, checking for active config upload...") - if self._config_upload_active: - logger.info("Application is quitting, cancelling active config upload...") - self._config_helper.send_config_request( - action="cancel", config=None, wait_for_response=True, timeout_s=10 - ) - logger.info("Config upload cancelled.") + # TODO Implement once issue #1012 is solved + # @SafeSlot() + # def _about_to_quit_handler(self): + # """Handle application about to quit event. If config upload is active, cancel it.""" + # logger.info("Application is quitting, checking for active config upload...") + # if self._config_upload_active: + # logger.info("Application is quitting, cancelling active config upload...") + # self._config_helper.send_config_request( + # action="cancel", config=None, wait_for_response=True, timeout_s=10 + # ) + # logger.info("Config upload cancelled.") def _set_busy_wrapper(self, enabled: bool): """Thin wrapper around set_busy to flip the state variable.""" @@ -669,7 +671,6 @@ def _cancel_device_config_upload(self): def _handle_cancel_config_upload_failed(self, exception: Exception): """Handle failure to cancel the config upload.""" - QMessageBox.critical(self, "Error Cancelling Upload", f"{str(exception)}") self._set_busy_wrapper(enabled=False) validation_results = self.device_table_view.get_validation_results() diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py index d7fdc68aa..0425c78bb 100644 --- a/tests/unit_tests/test_busy_loader.py +++ b/tests/unit_tests/test_busy_loader.py @@ -47,7 +47,7 @@ def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy): def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): overlay = getattr(widget_idle, "_busy_overlay", None) - assert overlay is None, "Overlay should be lazily created when idle" + assert overlay is not None widget_idle.set_busy(True) overlay = getattr(widget_idle, "_busy_overlay") @@ -120,7 +120,6 @@ def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): ss = frame.styleSheet() assert "dashed" in ss assert "border" in ss - assert "rgba(128, 128, 128, 110)" in ss def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py index 22aac8246..530b53c1b 100644 --- a/tests/unit_tests/test_device_initialization_progress_bar.py +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -20,10 +20,13 @@ def progress_bar(qtbot, mocked_client): def test_progress_bar_initialization(progress_bar): """Test the initial state of the DeviceInitializationProgressBar.""" assert progress_bar.failed_devices == [] - assert progress_bar._user_value == 0 - assert progress_bar._user_maximum == 1 + assert progress_bar.progress_bar._user_value == 0 + assert progress_bar.progress_bar._user_maximum == 100 assert progress_bar.toolTip() == "No device initialization failures." + assert progress_bar.progress_label.text() == "Initializing devices..." + assert progress_bar.group_box.title() == "Config Update Progress" + def test_update_device_initialization_progress(progress_bar, qtbot): """Test updating the progress bar with different device initialization messages.""" @@ -34,22 +37,19 @@ def test_update_device_initialization_progress(progress_bar, qtbot): ) progress_bar._update_device_initialization_progress(msg.model_dump(), {}) - assert progress_bar._user_value == 1 - assert progress_bar._user_maximum == 3 - assert ( - f"Device initialization for '{msg.device}' is in progress..." - in progress_bar.center_label.text() - ) + assert progress_bar.progress_bar._user_value == 1 + assert progress_bar.progress_bar._user_maximum == 3 + assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..." + assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() # II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True msg.finished = True msg.success = True progress_bar._update_device_initialization_progress(msg.model_dump(), {}) - assert progress_bar._user_value == 1 - assert progress_bar._user_maximum == 3 - assert ( - f"Device initialization for '{msg.device}' succeeded!" in progress_bar.center_label.text() - ) + assert progress_bar.progress_bar._user_value == 1 + assert progress_bar.progress_bar._user_maximum == 3 + assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!" + assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() # III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False msg.finished = True @@ -58,11 +58,11 @@ def test_update_device_initialization_progress(progress_bar, qtbot): msg.index = 2 with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker: progress_bar._update_device_initialization_progress(msg.model_dump(), {}) - assert progress_bar._user_value == 2 - assert progress_bar._user_maximum == 3 - assert ( - f"Device initialization for '{msg.device}' failed!" in progress_bar.center_label.text() - ) + assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!" + assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text() + assert progress_bar.progress_bar._user_value == 2 + assert progress_bar.progress_bar._user_maximum == 3 + assert signal_blocker.args == [[msg.device]] assert progress_bar.toolTip() == f"Failed devices: {msg.device}" From ee6fd5fb9e9d2200a2180dedfec95d1b0f2ef648 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 18:22:32 +0100 Subject: [PATCH 113/155] test cleanup add mocked client --- tests/unit_tests/test_device_manager_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 0ca779fb5..0691b6dfb 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -57,9 +57,9 @@ class TestDeviceManagerViewDialogs: """Test class for DeviceManagerView dialog interactions.""" @pytest.fixture - def mock_dm_view(self, qtbot): + def mock_dm_view(self, qtbot, mocked_client): """Fixture for DeviceManagerView.""" - widget = DeviceManagerDisplayWidget() + widget = DeviceManagerDisplayWidget(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget From b325d1bb4f3a0506f4c6193de59f437eecbcc546 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 19:30:43 +0100 Subject: [PATCH 114/155] fix(signal-label): Fix signal label cleanup, missing parent in constructors --- .../device_item/device_signal_display.py | 12 +++++++++++- .../widgets/utility/signal_label/signal_label.py | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py index d783a215e..56a92e04b 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py @@ -15,6 +15,7 @@ class SignalDisplay(BECWidget, QWidget): def __init__( self, + parent=None, client=None, device: str = "", config: ConnectionConfig = None, @@ -24,7 +25,14 @@ def __init__( ): """A widget to display all the signals from a given device, and allow getting a fresh reading.""" - super().__init__(client, config, gui_id, theme_update, **kwargs) + super().__init__( + parent=parent, + client=client, + config=config, + gui_id=gui_id, + theme_update=theme_update, + **kwargs, + ) self.get_bec_shortcuts() self._layout = QVBoxLayout() self.setLayout(self._layout) @@ -72,6 +80,7 @@ def _populate(self): ]: self._content_layout.addWidget( SignalLabel( + parent=self, device=self._device, signal=sig, show_select_button=False, @@ -81,6 +90,7 @@ def _populate(self): else: self._content_layout.addWidget( SignalLabel( + parent=self, device=self._device, signal=self._device, show_select_button=False, diff --git a/bec_widgets/widgets/utility/signal_label/signal_label.py b/bec_widgets/widgets/utility/signal_label/signal_label.py index f394db179..529d175b2 100644 --- a/bec_widgets/widgets/utility/signal_label/signal_label.py +++ b/bec_widgets/widgets/utility/signal_label/signal_label.py @@ -494,6 +494,7 @@ def cleanup(self): w.setLayout(QVBoxLayout()) w.layout().addWidget( SignalLabel( + parent=w, device="samx", signal="readback", custom_label="custom label:", @@ -501,7 +502,9 @@ def cleanup(self): show_select_button=False, ) ) - w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True)) + w.layout().addWidget( + SignalLabel(parent=w, device="samy", signal="readback", show_default_units=True) + ) l = SignalLabel() l.device = "bpm4i" l.signal = "bpm4i" From 2b5b7360aed3e8f4246d87e6f7fc567bd2dbe78f Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 19:56:55 +0100 Subject: [PATCH 115/155] test(device-manager-view): improve test coverage for device-manager-view --- .../device_manager_display_widget.py | 48 +++-------- tests/unit_tests/test_device_manager_view.py | 86 +++++++++++++++++++ 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index b414090db..640f6b20a 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -77,27 +77,27 @@ def __init__(self, parent=None, client: BECClient | None = None): super().__init__(parent=parent) # Widgets - progress = DeviceInitializationProgressBar(parent=self, client=client) - progress.setMinimumWidth(320) + self.progress = DeviceInitializationProgressBar(parent=self, client=client) + self.progress.setMinimumWidth(320) # Spinner - spinner = SpinnerWidget(parent=self) + self.spinner = SpinnerWidget(parent=self) scale = self._ui_scale() spinner_size = int(scale * 0.12) if scale else 1 spinner_size = max(32, min(spinner_size, 96)) - spinner.setFixedSize(spinner_size, spinner_size) + self.spinner.setFixedSize(spinner_size, spinner_size) # Cancel button - cancel_button = QPushButton("Cancel Upload", parent=self) - cancel_button.setIcon(material_icon("cancel")) - cancel_button.clicked.connect(self.cancel_requested.emit) + self.cancel_button = QPushButton("Cancel Upload", parent=self) + self.cancel_button.setIcon(material_icon("cancel")) + self.cancel_button.clicked.connect(self.cancel_requested.emit) button_height = int(spinner_size * 0.9) button_height = max(36, min(button_height, 72)) aspect_ratio = 3.8 # width / height, visually stable for text buttons button_width = int(button_height * aspect_ratio) - cancel_button.setFixedSize(button_width, button_height) + self.cancel_button.setFixedSize(button_width, button_height) color = get_accent_colors() - cancel_button.setStyleSheet( + self.cancel_button.setStyleSheet( f""" QPushButton {{ background-color: {color.emergency.name()}; @@ -113,10 +113,10 @@ def __init__(self, parent=None, client: BECClient | None = None): content_layout.setContentsMargins(24, 24, 24, 24) content_layout.setSpacing(16) content_layout.addStretch() - content_layout.addWidget(spinner, 0, Qt.AlignmentFlag.AlignHCenter) - content_layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter) content_layout.addStretch() - content_layout.addWidget(cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) if hasattr(color, "_colors"): bg_color = color._colors.get("BG", None) @@ -138,14 +138,12 @@ def _ui_scale(self) -> int: def showEvent(self, event): """Show event to start the spinner.""" super().showEvent(event) - for child in self.findChildren(SpinnerWidget): - child.start() + self.spinner.start() def hideEvent(self, event): """Hide event to stop the spinner.""" super().hideEvent(event) - for child in self.findChildren(SpinnerWidget): - child.stop() + self.spinner.stop() class DeviceManagerDisplayWidget(DockAreaWidget): @@ -171,9 +169,6 @@ def __init__(self, parent=None, *args, **kwargs): self._config_helper = config_helper.ConfigHelper(self.client.connector) self._shared_selection = SharedSelectionSignal() - # Custom upload widget for busy overlay - self._custom_overlay_widget: QWidget | None = None - # Device Table View widget self.device_table_view = DeviceTable(self) @@ -687,20 +682,9 @@ def _handle_cancel_config_upload_failed(self, exception: Exception): # Config is in sync with BEC, so we update the state self.device_table_view.device_config_in_sync_with_redis.emit(False) - # Cleanup custom overlay widget - if self._custom_overlay_widget is not None: - self._custom_overlay_widget.close() - self._custom_overlay_widget.deleteLater() - self._custom_overlay_widget = None - def _handle_push_complete_to_communicator(self): """Handle completion of the config push to Redis.""" self._set_busy_wrapper(enabled=False) - # Cleanup custom overlay widget - if self._custom_overlay_widget is not None: - self._custom_overlay_widget.close() - self._custom_overlay_widget.deleteLater() - self._custom_overlay_widget = None def _handle_exception_from_communicator(self, exception: Exception): """Handle exceptions from the config communicator.""" @@ -710,10 +694,6 @@ def _handle_exception_from_communicator(self, exception: Exception): f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", ) self._set_busy_wrapper(enabled=False) - if self._custom_overlay_widget is not None: - self._custom_overlay_widget.close() - self._custom_overlay_widget.deleteLater() - self._custom_overlay_widget = None @SafeSlot() def _save_to_disk_action(self): diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 0691b6dfb..e7326c537 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -30,6 +30,7 @@ DeviceManagerView, DeviceManagerWidget, ) +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.widgets.control.device_manager.components import ( DeviceTable, DMConfigView, @@ -612,6 +613,34 @@ def device_configs(self, device_config: dict): cfg_iter.append(dev_config_copy) return cfg_iter + def test_custom_busy_widget(self, custom_busy: CustomBusyWidget, qtbot): + """Test the CustomBusyWidget functionality.""" + + # Check layout + assert custom_busy.progress is not None + assert custom_busy.spinner is not None + assert custom_busy.spinner._started is False + + # Check background + color = get_accent_colors() + bg = color._colors["BG"] + sheet = custom_busy.styleSheet() + assert bg.name() in sheet + assert "border-radius: 12px" in sheet + + # Show event should start spinner + custom_busy.showEvent(None) + assert custom_busy.spinner._started is True + + with qtbot.waitSignal(custom_busy.cancel_requested) as sig_blocker: + qtbot.mouseClick(custom_busy.cancel_button, QtCore.Qt.LeftButton) + # Check that the signal was emitted + assert sig_blocker.signal_triggered is True + + # Hide should + custom_busy.hideEvent(None) + assert custom_busy.spinner._started is False + def test_device_manager_view_add_remove_device( self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config ): @@ -750,3 +779,60 @@ def test_run_validate_connection_action_connected( "rerun_validation" ].action.action.triggered.emit() assert len(mock_change_configs.call_args[0][0]) == 1 + + def test_handle_cancel_config_upload_failed( + self, device_manager_display_widget: DeviceManagerDisplayWidget, qtbot + ): + """Test handling cancel during config upload failure.""" + dm_view = device_manager_display_widget + validation_results = { + "Device_1": ( + {"name": "Device_1"}, + ConfigStatus.VALID.value, + ConnectionStatus.CANNOT_CONNECT.value, + ), + "Device_2": ( + {"name": "Device_2"}, + ConfigStatus.INVALID.value, + ConnectionStatus.UNKNOWN.value, + ), + } + with mock.patch.object( + dm_view.device_table_view, "get_validation_results", return_value=validation_results + ): + with ( + mock.patch.object( + dm_view.device_table_view, "update_multiple_device_validations" + ) as mock_update, + mock.patch.object( + dm_view.ophyd_test_view, "change_device_configs" + ) as mock_change_configs, + ): + with qtbot.waitSignal( + dm_view.device_table_view.device_config_in_sync_with_redis + ) as sig_blocker: + dm_view._handle_cancel_config_upload_failed( + exception=Exception("Test Exception") + ) + assert sig_blocker.signal_triggered is True + mock_change_configs.assert_called_once_with( + [validation_results["Device_1"][0], validation_results["Device_2"][0]], + added=True, + skip_validation=False, + ) + mock_update.assert_called_once_with( + [ + ( + validation_results["Device_1"][0], + validation_results["Device_1"][1], + ConnectionStatus.UNKNOWN.value, + "Upload Cancelled", + ), + ( + validation_results["Device_2"][0], + validation_results["Device_2"][1], + ConnectionStatus.UNKNOWN.value, + "Upload Cancelled", + ), + ] + ) From 322655fc5ebe09b86222b5a5583e220b1ccba91a Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 20:15:51 +0100 Subject: [PATCH 116/155] refactor(busy-loager): Improve eventFilter to avoid crashs if target or overlay is None. --- bec_widgets/utils/busy_loader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index 4df122eff..c88195f7f 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -1,5 +1,6 @@ from __future__ import annotations +from bec_lib.logger import bec_logger from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import ( @@ -16,6 +17,8 @@ from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty +logger = bec_logger.logger + class _OverlayEventFilter(QObject): """Keeps the overlay sized and stacked over its target widget.""" @@ -26,6 +29,10 @@ def __init__(self, target: QWidget, overlay: QWidget): self._overlay = overlay def eventFilter(self, obj, event): + if not hasattr(self, " _target") or self._target is None: + return False + if not hasattr(self, "_overlay") or self._overlay is None: + return False if obj is self._target and event.type() in ( QEvent.Resize, QEvent.Show, From 8e173cb17e7a2767411dec3baff02dc7fbdd4e06 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 20:23:53 +0100 Subject: [PATCH 117/155] refactor(device-form-dialog): Use native QDialogButtonBox instead of GroupBox layout --- .../device_manager_dialogs/device_form_dialog.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 167cfbfff..fa38d3542 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -177,12 +177,17 @@ def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignor self.cancel_btn = QtWidgets.QPushButton("Cancel") self.reset_btn = QtWidgets.QPushButton("Reset Form") - btn_layout = QtWidgets.QHBoxLayout() - for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn): + btn_box = QtWidgets.QDialogButtonBox(self) + btn_box.addButton(self.cancel_btn, QtWidgets.QDialogButtonBox.ButtonRole.RejectRole) + btn_box.addButton(self.reset_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole) + btn_box.addButton( + self.test_connection_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole + ) + btn_box.addButton(self.add_btn, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole) + for btn in btn_box.buttons(): btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) - btn_layout.addWidget(btn) - btn_box = QtWidgets.QGroupBox("Actions") - btn_box.setLayout(btn_layout) + layout.addWidget(btn_box) + frame_layout.addWidget(btn_box) # Connect signals to explicit slots From 36fa0e649ca16f9e0a081584124bcce3588bbe2b Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 16 Jan 2026 23:05:45 +0100 Subject: [PATCH 118/155] fix(_OverlayEventFilter): fix typo --- .../device_manager_dialogs/device_form_dialog.py | 1 - bec_widgets/utils/bec_widget.py | 2 +- bec_widgets/utils/busy_loader.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index fa38d3542..1f4b574fc 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -6,7 +6,6 @@ from bec_lib.logger import bec_logger from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES from qtpy import QtCore, QtWidgets -from zmq.devices import Device from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.device_manager.components import OphydValidation diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index bb48b5271..e091e0a2e 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -165,7 +165,7 @@ def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: overlay = getattr(self, "_busy_overlay", None) if overlay is None: - overlay = install_busy_loader(self, start_loading=False) + overlay = install_busy_loader(target=self, start_loading=False) self._busy_overlay = overlay # Create and set the busy state widget diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index c88195f7f..9784c6cea 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -29,7 +29,7 @@ def __init__(self, target: QWidget, overlay: QWidget): self._overlay = overlay def eventFilter(self, obj, event): - if not hasattr(self, " _target") or self._target is None: + if not hasattr(self, "_target") or self._target is None: return False if not hasattr(self, "_overlay") or self._overlay is None: return False @@ -252,7 +252,7 @@ def install_busy_loader( """ overlay = BusyLoaderOverlay(parent=target, opacity=opacity) overlay.setGeometry(target.rect()) - overlay.set_filter(_OverlayEventFilter(target, overlay)) + overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay)) if start_loading: overlay.show() return overlay From cc89252fb3928084095816f1e8df1e3bda539d4d Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 19 Jan 2026 14:53:25 +0100 Subject: [PATCH 119/155] fix: 'Any' type annotations --- bec_widgets/utils/reference_utils.py | 3 ++- .../device_config_template/device_config_template.py | 4 ++-- .../components/device_config_template/template_items.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bec_widgets/utils/reference_utils.py b/bec_widgets/utils/reference_utils.py index 6766a13ea..dc4ef78ef 100644 --- a/bec_widgets/utils/reference_utils.py +++ b/bec_widgets/utils/reference_utils.py @@ -1,5 +1,6 @@ import os import sys +from typing import Any from PIL import Image, ImageChops from qtpy.QtGui import QPixmap @@ -40,7 +41,7 @@ def compare_images(image1_path: str, reference_image_path: str): raise ValueError("Images are different") -def snap_and_compare(widget: any, output_directory: str, suffix: str = ""): +def snap_and_compare(widget: Any, output_directory: str, suffix: str = ""): """ Save a rendering of a widget and compare it to a reference image diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py index ad25fa05a..508cef68b 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/device_config_template.py @@ -1,7 +1,7 @@ """Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV""" from copy import deepcopy -from typing import Type +from typing import Any, Type from bec_lib.atlas_models import Device as DeviceModel from bec_lib.logger import bec_logger @@ -191,7 +191,7 @@ def _fill_fields_from_config(self, model: dict) -> None: if widget is not None: self._set_value_for_widget(widget, value) - def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None: + def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: Any) -> None: """ Set the value for a widget based on its type. diff --git a/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py index 47d389508..9dc661589 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py +++ b/bec_widgets/widgets/control/device_manager/components/device_config_template/template_items.py @@ -1,7 +1,7 @@ """Module for custom input widgets used in device configuration templates.""" from ast import literal_eval -from typing import Callable +from typing import Any, Callable from bec_lib.logger import bec_logger from bec_qthemes import material_icon @@ -15,7 +15,7 @@ logger = bec_logger.logger -def _try_literal_eval(value: any) -> any: +def _try_literal_eval(value: str) -> Any: """Consolidated function for literal evaluation of a value.""" if value in ["true", "True"]: return True @@ -407,7 +407,7 @@ class DeviceConfigField(BaseModel): static: bool = False placeholder_text: str | None = None validation_callback: list[Callable[[str], bool]] | None = None - default: any = None + default: Any = None model_config = ConfigDict(arbitrary_types_allowed=True) From 75162ef8a8017624b71393833b97e966e7332edb Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 19 Jan 2026 15:19:05 +0100 Subject: [PATCH 120/155] fix: remove manual stylesheet deletion/override --- .../upload_redis_dialog.py | 3 ++- .../ophyd_validation/ophyd_validation.py | 2 -- .../ophyd_validation/validation_list_item.py | 16 ---------------- .../progress/bec_progressbar/bec_progressbar.py | 4 +--- .../device_initialization_progress_bar.py | 14 -------------- 5 files changed, 3 insertions(+), 36 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py index e4e746df5..ec4e7f014 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/upload_redis_dialog.py @@ -513,7 +513,8 @@ def _handle_upload(self): [ detailed_text, "These devices may not be reachable and disabled BEC upon loading the config.", - "Consider validating these connections before.", + "Consider validating these connections before proceeding.\n\n", + "Continue anyway?", ] ) reply = QtWidgets.QMessageBox.critical( diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index 103958797..f6ff0dd80 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -264,7 +264,6 @@ def __init__(self, parent=None): icon = self._icons["config_status"][status] icon_widget = ValidationButton(parent=self, icon=icon) icon_widget.setEnabled(False) - icon_widget.set_enabled_style(False) icon_widget.setToolTip(f"Device Configuration: {status.description()}") layout.addWidget(icon_widget, 0, ii + 1) @@ -282,7 +281,6 @@ def __init__(self, parent=None): icon = self._icons["connection_status"][status] icon_widget = ValidationButton(parent=self, icon=icon) icon_widget.setEnabled(False) - icon_widget.set_enabled_style(False) icon_widget.setToolTip(f"Connection Status: {status.description()}") layout.addWidget(icon_widget, 1, ii + 1) layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py index 3fe846685..6b5d84f80 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py @@ -38,20 +38,8 @@ def __init__( self.setEnabled(True) def setEnabled(self, enabled: bool) -> None: - self.set_enabled_style(enabled) return super().setEnabled(enabled) - def set_enabled_style(self, enabled: bool) -> None: - """Set the enabled state of the button with style update. - - Args: - enabled (bool): Whether the button should be enabled. - """ - if enabled: - self.setStyleSheet("") - else: - self.setStyleSheet(self.transparent_style) - class ValidationDialog(QtWidgets.QDialog): """ @@ -308,13 +296,11 @@ def on_validation_finished( # Enable/disable buttons based on status config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID] self.status_button.setEnabled(config_but_en) - self.status_button.set_enabled_style(config_but_en) connect_but_en = connection_status in [ ConnectionStatus.UNKNOWN, ConnectionStatus.CANNOT_CONNECT, ] self.connection_button.setEnabled(connect_but_en) - self.connection_button.set_enabled_style(connect_but_en) @SafeSlot() def validation_scheduled(self): @@ -323,9 +309,7 @@ def validation_scheduled(self): "Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN ) self.status_button.setEnabled(False) - self.status_button.set_enabled_style(False) self.connection_button.setEnabled(False) - self.connection_button.set_enabled_style(False) self._spinner.setVisible(True) @SafeSlot() diff --git a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py index c486ac311..7db459754 100644 --- a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +++ b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py @@ -82,7 +82,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # Color settings self._background_color = QColor(30, 30, 30) - self._progress_color = accent_colors.highlight # QColor(210, 55, 130) + self._progress_color = accent_colors.highlight self._completed_color = accent_colors.success self._border_color = QColor(50, 50, 50) @@ -91,7 +91,6 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # Progress‑bar state handling self._state = ProgressState.NORMAL - # self._state_colors = dict(PROGRESS_STATE_COLORS) self._state_colors = { ProgressState.NORMAL: accent_colors.default, @@ -109,7 +108,6 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # label on top of the progress bar self.center_label = QLabel(self) self.center_label.setAlignment(Qt.AlignHCenter) - self.center_label.setStyleSheet("color: white;") self.center_label.setMinimumSize(0, 0) layout = QVBoxLayout(self) diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py index 07fe5ba37..f23034d3f 100644 --- a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -3,7 +3,6 @@ from bec_lib.endpoints import MessageEndpoints from bec_lib.messages import DeviceInitializationProgressMessage from qtpy.QtCore import Signal -from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget @@ -37,7 +36,6 @@ def __init__(self, parent=None, client=None, **kwargs): self.progress_bar.label_template = "$value / $maximum - $percentage %" self.progress_label = QLabel("Initializing devices...", self) - self.progress_label.setStyleSheet("font-size: 12px; font-weight: cursive;") content_layout = QVBoxLayout() content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(0) @@ -60,18 +58,6 @@ def __init__(self, parent=None, client=None, **kwargs): ) self._reset_progress_bar() - def _update_palette(self) -> None: - """Update theme palette for the widget.""" - _app = QApplication.instance() - if hasattr(_app, "theme"): - theme = _app.theme # type: ignore[attr-defined] - text_color = theme.color("FG") - else: - text_color = QColor(230, 230, 230) - self.progress_label.setStyleSheet( - f"color: {text_color.name()}; font-size: 12px; font-weight: cursive;" - ) - @SafeProperty(list) def failed_devices(self) -> list[str]: """Get the list of devices that failed to initialize. From beb337201c4e463975ea40760e83f7ea12e60756 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 21 Jan 2026 09:28:25 +0100 Subject: [PATCH 121/155] feat: attach config cancellation to closeEvent --- .../device_manager_display_widget.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 640f6b20a..92d036e22 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -226,9 +226,9 @@ def __init__(self, parent=None, *args, **kwargs): # Build dock layout using shared helpers self._build_docks() - # TODO Implement once issue #1012 is solved - # logger.info("Connecting application about to quit signal to device manager view...") - # QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler) + def closeEvent(self, event): + self._about_to_quit_handler() + return super().closeEvent(event) ############################## ### Custom set busy widget ### @@ -244,17 +244,15 @@ def create_busy_state_widget(self) -> QWidget: ### Application quit handler ### ################################ - # TODO Implement once issue #1012 is solved - # @SafeSlot() - # def _about_to_quit_handler(self): - # """Handle application about to quit event. If config upload is active, cancel it.""" - # logger.info("Application is quitting, checking for active config upload...") - # if self._config_upload_active: - # logger.info("Application is quitting, cancelling active config upload...") - # self._config_helper.send_config_request( - # action="cancel", config=None, wait_for_response=True, timeout_s=10 - # ) - # logger.info("Config upload cancelled.") + def _about_to_quit_handler(self): + """Handle application about to quit event. If config upload is active, cancel it.""" + logger.info("Application is quitting, checking for active config upload...") + if self._config_upload_active: + logger.info("Application is quitting, cancelling active config upload...") + self._config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Config upload cancelled.") def _set_busy_wrapper(self, enabled: bool): """Thin wrapper around set_busy to flip the state variable.""" From dc1072c2478ef34525d762c43999823317392258 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 21 Jan 2026 11:05:52 +0100 Subject: [PATCH 122/155] fix: tooltip logic and disable button on running scan --- .../device_manager_display_widget.py | 77 ++++++++++++------- .../bec_progressbar/bec_progressbar.py | 1 + 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 92d036e22..db47a99a3 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -7,9 +7,11 @@ import yaml from bec_lib import config_helper from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.callback_handler import EventType +from bec_lib.endpoints import MessageEndpoints from bec_lib.file_utils import DeviceConfigWriter from bec_lib.logger import bec_logger -from bec_lib.messages import ConfigAction +from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme, material_icon from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal @@ -158,6 +160,10 @@ def __init__(self, parent=None, *args, **kwargs): # State variable for config upload self._config_upload_active: bool = False + self._config_in_sync: bool = False + scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status()) + initial_status = scan_status.status if scan_status is not None else "closed" + self._scan_is_running: bool = initial_status not in ["open", "paused"] # Push to Redis dialog self._upload_redis_dialog: UploadRedisDialog | None = None @@ -213,22 +219,37 @@ def __init__(self, parent=None, *args, **kwargs): ), ( self.device_table_view.device_config_in_sync_with_redis, - (self._update_config_enabled_button,), + (self._update_config_in_sync,), ), (self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)), ]: for slot in slots: signal.connect(slot) + self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register( + EventType.SCAN_STATUS, self._update_scan_running + ) + # Add toolbar self._add_toolbar() # Build dock layout using shared helpers self._build_docks() + def cleanup(self): + self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id) + super().cleanup() + def closeEvent(self, event): - self._about_to_quit_handler() - return super().closeEvent(event) + """If config upload is active when application is exiting, cancel it.""" + logger.info("Application is quitting, checking for active config upload...") + if self._config_upload_active: + logger.info("Application is quitting, cancelling active config upload...") + self._config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Config upload cancelled.") + super().closeEvent(event) ############################## ### Custom set busy widget ### @@ -240,20 +261,6 @@ def create_busy_state_widget(self) -> QWidget: widget.cancel_requested.connect(self._cancel_device_config_upload) return widget - ################################ - ### Application quit handler ### - ################################ - - def _about_to_quit_handler(self): - """Handle application about to quit event. If config upload is active, cancel it.""" - logger.info("Application is quitting, checking for active config upload...") - if self._config_upload_active: - logger.info("Application is quitting, cancelling active config upload...") - self._config_helper.send_config_request( - action="cancel", config=None, wait_for_response=True, timeout_s=10 - ) - logger.info("Config upload cancelled.") - def _set_busy_wrapper(self, enabled: bool): """Thin wrapper around set_busy to flip the state variable.""" self._busy_overlay.set_opacity(0.92) @@ -445,6 +452,32 @@ def _add_table_actions(self) -> None: # Add load config from plugin dir self.toolbar.add_bundle(table_bundle) + ###################################### + ### Update button state management ### + ###################################### + + @SafeSlot(dict, dict) + def _update_scan_running(self, scan_info: dict, _: dict): + """disable editing when scans are running and enable editing when they are finished""" + msg = ScanStatusMessage.model_validate(scan_info) + self._scan_is_running = msg.status in ["open", "paused"] + self._update_config_enabled_button() + + def _update_config_in_sync(self, in_sync: bool): + self._config_in_sync = in_sync + self._update_config_enabled_button() + + def _update_config_enabled_button(self): + action = self.toolbar.components.get_action("update_config_redis") + enabled = not self._config_in_sync and not self._scan_is_running + action.action.setEnabled(enabled) + if enabled: # button is enabled + action.action.setToolTip("Push current config to BEC Server") + elif self._scan_is_running: + action.action.setToolTip("Scan is currently running, config updates disabled.") + else: + action.action.setToolTip("Current config is in sync with BEC Server, updates disabled.") + ####################### ### Action Handlers ### ####################### @@ -465,14 +498,6 @@ def _run_validate_connection(self, connect: bool = True): ) self.request_ophyd_validation.emit(configs, True, connect) - def _update_config_enabled_button(self, enabled: bool): - action = self.toolbar.components.get_action("update_config_redis") - action.action.setEnabled(not enabled) - if enabled: - action.action.setToolTip("Push current config to BEC Server") - else: - action.action.setToolTip("Current config is in sync with BEC Server, button disabled.") - @SafeSlot() def _load_file_action(self): """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" diff --git a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py index 7db459754..2e758e226 100644 --- a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +++ b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py @@ -109,6 +109,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) self.center_label = QLabel(self) self.center_label.setAlignment(Qt.AlignHCenter) self.center_label.setMinimumSize(0, 0) + self.center_label.setStyleSheet("background: transparent; color: white;") layout = QVBoxLayout(self) layout.setContentsMargins(10, 0, 10, 0) From d67227d20ca3f80ba0f1235d4d7d849dc6848f0e Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 21 Jan 2026 11:13:28 +0100 Subject: [PATCH 123/155] test: fix test --- .../components/ophyd_validation/validation_list_item.py | 1 - tests/unit_tests/test_device_manager_components.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py index 6b5d84f80..f8566f2de 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/validation_list_item.py @@ -31,7 +31,6 @@ def __init__( self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None ) -> None: super().__init__(parent=parent) - self.transparent_style = "background-color: transparent; border: none;" if icon: self.setIcon(icon) self.setFlat(True) diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index 1131929e1..771dc2df7 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -770,7 +770,7 @@ def test_validation_button_initialization(self, validation_button: ValidationBut assert isinstance(validation_button.icon(), QtGui.QIcon) assert validation_button.styleSheet() == "" validation_button.setEnabled(False) - assert validation_button.styleSheet() == validation_button.transparent_style + assert validation_button.styleSheet() == "" @pytest.fixture def validation_dialog(self, qtbot): From 153fb62a049a9baf80f7f3ca8e43fbcf52994d75 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 21 Jan 2026 11:27:29 +0100 Subject: [PATCH 124/155] style: wrap progress bar in widget to fix background --- .../device_manager_view/device_manager_display_widget.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index db47a99a3..10d49d41b 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -79,7 +79,11 @@ def __init__(self, parent=None, client: BECClient | None = None): super().__init__(parent=parent) # Widgets - self.progress = DeviceInitializationProgressBar(parent=self, client=client) + self.progress = QWidget(parent=self) + self.progress_layout = QVBoxLayout(self.progress) + self.progress_layout.setContentsMargins(6, 6, 6, 6) + self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client) + self.progress_layout.addWidget(self.progress_inner) self.progress.setMinimumWidth(320) # Spinner @@ -163,7 +167,7 @@ def __init__(self, parent=None, *args, **kwargs): self._config_in_sync: bool = False scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status()) initial_status = scan_status.status if scan_status is not None else "closed" - self._scan_is_running: bool = initial_status not in ["open", "paused"] + self._scan_is_running: bool = initial_status in ["open", "paused"] # Push to Redis dialog self._upload_redis_dialog: UploadRedisDialog | None = None From d8ebae49ad975f66f90677e2a936dc507e97cae5 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 22 Jan 2026 11:24:23 +0100 Subject: [PATCH 125/155] fix(device-progress-bar): remove stretch in content layout --- .../device_initialization_progress_bar.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py index f23034d3f..a58398249 100644 --- a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -47,7 +47,6 @@ def __init__(self, parent=None, client=None, **kwargs): hor_layout.setContentsMargins(12, 0, 0, 0) hor_layout.addWidget(self.progress_label) content_layout.addLayout(hor_layout) - content_layout.addStretch() # Add content layout to main layout lay.addLayout(content_layout) From 0b212c31005fbfb1ff318ef28ca67897a6b09719 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 27 Jan 2026 14:33:37 +0100 Subject: [PATCH 126/155] fix(main_window): parent fixed for notification broker --- bec_widgets/widgets/containers/main_window/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 2878d2686..55fdf1f1f 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -54,7 +54,7 @@ def __init__(self, parent=None, window_title: str = "BEC", **kwargs): # Notification Centre overlay self.notification_centre = NotificationCentre(parent=self) # Notification layer - self.notification_broker = BECNotificationBroker() + self.notification_broker = BECNotificationBroker(parent=self) self._nc_margin = 16 self._position_notification_centre() From 44f7acaeda3963316a11cd1c3c6ecdb71eccac29 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 27 Jan 2026 14:34:14 +0100 Subject: [PATCH 127/155] fix(launch_window): logic for showing launcher --- bec_widgets/applications/launch_window.py | 17 +++- tests/unit_tests/test_launch_window.py | 94 +++++++++-------------- 2 files changed, 49 insertions(+), 62 deletions(-) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 25b2c0d48..cb53555d5 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -662,10 +662,19 @@ def _launcher_is_last_widget(self, connections: dict) -> bool: Check if the launcher is the last widget in the application. """ - remaining_connections = [ - connection for connection in connections.values() if connection.parent_id != self.gui_id - ] - return len(remaining_connections) <= 4 + # get all parents of connections + for connection in connections.values(): + try: + parent = connection.parent() + if parent is None and connection.objectName() != self.objectName(): + logger.info( + f"Found non-launcher connection without parent: {connection.objectName()}" + ) + return False + except Exception as e: + logger.error(f"Error getting parent of connection: {e}") + return False + return True def _turn_off_the_lights(self, connections: dict): """ diff --git a/tests/unit_tests/test_launch_window.py b/tests/unit_tests/test_launch_window.py index f23e51654..7967df65b 100644 --- a/tests/unit_tests/test_launch_window.py +++ b/tests/unit_tests/test_launch_window.py @@ -85,41 +85,30 @@ class PluginAutoUpdate(AutoUpdates): ... @pytest.mark.parametrize( - "connections, hide", + "connection_names, hide", [ - ({}, False), - ({"launcher": mock.MagicMock()}, False), - ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, False), + ([], False), + (["launcher"], False), + (["launcher", "dock_area"], False), + (["launcher", "dock_area", "scan_progress"], False), + (["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], False), ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress": mock.MagicMock(), - }, - False, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - }, - False, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - "hover_widget": mock.MagicMock(), - }, + ["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"], True, ), ], ) -def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide): +def test_gui_server_turns_off_the_lights(bec_launch_window, connection_names, hide): + connections = {} + for name in connection_names: + conn = mock.MagicMock() + if name == "hover_widget": + conn.parent.return_value = None + conn.objectName.return_value = "HoverWidget" + else: + conn.parent.return_value = mock.MagicMock() + conn.objectName.return_value = bec_launch_window.objectName() + connections[name] = conn with ( mock.patch.object(bec_launch_window, "show") as mock_show, mock.patch.object(bec_launch_window, "activateWindow") as mock_activate_window, @@ -142,46 +131,35 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, connections, hide): @pytest.mark.parametrize( - "connections, close_called", + "connection_names, close_called", [ - ({}, True), - ({"launcher": mock.MagicMock()}, True), - ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True), + ([], True), + (["launcher"], True), + (["launcher", "dock_area"], True), + (["launcher", "dock_area", "scan_progress"], True), + (["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], True), ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress": mock.MagicMock(), - }, - True, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - }, - True, - ), - ( - { - "launcher": mock.MagicMock(), - "dock_area": mock.MagicMock(), - "scan_progress_simple": mock.MagicMock(), - "scan_progress_full": mock.MagicMock(), - "hover_widget": mock.MagicMock(), - }, + ["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"], False, ), ], ) -def test_launch_window_closes(bec_launch_window, connections, close_called): +def test_launch_window_closes(bec_launch_window, connection_names, close_called): """ Test that the close event is handled correctly based on the connections. If there are no connections or only the launcher connection, the window should close. If there are other connections, the window should hide instead of closing. """ + connections = {} + for name in connection_names: + conn = mock.MagicMock() + if name == "hover_widget": + conn.parent.return_value = None + conn.objectName.return_value = "HoverWidget" + else: + conn.parent.return_value = mock.MagicMock() + conn.objectName.return_value = bec_launch_window.objectName() + connections[name] = conn close_event = mock.MagicMock() with mock.patch.object( bec_launch_window.register, "list_all_connections", return_value=connections From 43c311782da99a30e87ba582142513170c333ecf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 27 Jan 2026 18:45:45 +0100 Subject: [PATCH 128/155] fix(rpc_register): listing only valid connections --- bec_widgets/cli/rpc/rpc_register.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bec_widgets/cli/rpc/rpc_register.py b/bec_widgets/cli/rpc/rpc_register.py index b984e3338..0b0077746 100644 --- a/bec_widgets/cli/rpc/rpc_register.py +++ b/bec_widgets/cli/rpc/rpc_register.py @@ -5,14 +5,13 @@ from typing import TYPE_CHECKING, Callable from weakref import WeakValueDictionary +import shiboken6 as shb from bec_lib.logger import bec_logger from qtpy.QtCore import QObject if TYPE_CHECKING: # pragma: no cover from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_widget import BECWidget - from bec_widgets.widgets.containers.dock.dock import BECDock - from bec_widgets.widgets.containers.dock.dock_area import BECDockArea logger = bec_logger.logger @@ -109,11 +108,19 @@ def list_all_connections(self) -> dict: dict: A dictionary containing all the registered RPC objects. """ with self._lock: - connections = dict(self._rpc_register) + connections = {} + for gui_id, obj in self._rpc_register.items(): + try: + if not shb.isValid(obj): + continue + connections[gui_id] = obj + except Exception as e: + logger.warning(f"Error checking validity of object {gui_id}: {e}") + continue return connections def get_names_of_rpc_by_class_type( - self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea] + self, cls: type[BECWidget] | type[BECConnector] ) -> list[str]: """Get all the names of the widgets. From d48b9d224fed4dd388209fdf25997b0d26e32d2b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 21 Jan 2026 15:45:21 +0100 Subject: [PATCH 129/155] feat: add export and load settings methods to BECConnector; add SafeProperty safe getter flag --- bec_widgets/utils/bec_connector.py | 58 +++++++++++++++++++++++++- bec_widgets/utils/error_popups.py | 2 + tests/unit_tests/test_bec_connector.py | 33 ++++++++++++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 0c720a090..81032e270 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -12,7 +12,7 @@ from bec_lib.logger import bec_logger from bec_lib.utils.import_utils import lazy_import_from from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal +from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal from qtpy.QtWidgets import QApplication from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -480,6 +480,62 @@ def get_config(self, dict_output: bool = True) -> dict | BaseModel: else: return self.config + def export_settings(self) -> dict: + """ + Export the settings of the widget as dict. + + Returns: + dict: The exported settings of the widget. + """ + + # We first get all qproperties that were defined in a bec_widgets class + objs = self._get_bec_meta_objects() + settings = {} + for prop_name in objs.keys(): + try: + prop_value = getattr(self, prop_name) + settings[prop_name] = prop_value + except Exception as e: + logger.warning( + f"Could not export property '{prop_name}' from '{self.__class__.__name__}': {e}" + ) + return settings + + def load_settings(self, settings: dict) -> None: + """ + Load the settings of the widget from dict. + + Args: + settings (dict): The settings to load into the widget. + """ + objs = self._get_bec_meta_objects() + for prop_name, prop_value in settings.items(): + if prop_name in objs: + try: + setattr(self, prop_name, prop_value) + except Exception as e: + logger.warning( + f"Could not load property '{prop_name}' into '{self.__class__.__name__}': {e}" + ) + + def _get_bec_meta_objects(self) -> dict: + """ + Get BEC meta objects for the widget. + + Returns: + dict: BEC meta objects. + """ + if not isinstance(self, QObject): + return {} + objects = {} + for name, attr in vars(self.__class__).items(): + if isinstance(attr, Property): + # Check if the property is a SafeProperty + is_safe_property = getattr(attr.fget, "__is_safe_getter__", False) + if is_safe_property: + objects[name] = attr + return objects + # --- Example usage of BECConnector: running a simple task --- if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 2e5864078..7d52af4b9 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -53,6 +53,8 @@ def safe_getter(self_): logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}") return default + safe_getter.__is_safe_getter__ = True # type: ignore[attr-defined] + class PropertyWrapper: """ Intermediate wrapper used so that the user can optionally chain .setter(...). diff --git a/tests/unit_tests/test_bec_connector.py b/tests/unit_tests/test_bec_connector.py index 62b88ca76..4610fbc3c 100644 --- a/tests/unit_tests/test_bec_connector.py +++ b/tests/unit_tests/test_bec_connector.py @@ -3,9 +3,10 @@ import pytest from qtpy.QtCore import QObject -from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QApplication, QWidget from bec_widgets.utils import BECConnector +from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.error_popups import SafeSlot as Slot from .client_mocks import mocked_client @@ -131,3 +132,33 @@ def test_bec_connector_change_object_name(bec_connector): # Verify that the object with the previous name is no longer registered all_objects = bec_connector.rpc_register.list_all_connections().values() assert not any(obj.objectName() == previous_name for obj in all_objects) + + +def test_bec_connector_export_settings(): + + class MyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.setWindowTitle("My Widget") + self._my_str_property = "default" + + @SafeProperty(str) + def my_str_property(self) -> str: + return self._my_str_property + + @my_str_property.setter + def my_str_property(self, value: str): + self._my_str_property = value + + @property + def my_int_property(self) -> int: + return 42 + + widget = MyWidget(client=mocked_client) + out = widget.export_settings() + assert len(out) == 1 + assert out["my_str_property"] == "default" + + config = {"my_str_property": "new_value"} + widget.load_settings(config) + assert widget.my_str_property == "new_value" From 9290a9a23bd2c4f891718a6a0782e3f8f7b8b0da Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 22 Jan 2026 15:29:11 +0100 Subject: [PATCH 130/155] feat(color): add relative luminance calculation --- bec_widgets/utils/colors.py | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 6b55d7f53..789338fce 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -467,3 +467,85 @@ def validate_color_map(color_map: str, return_error: bool = True) -> str | bool: else: return False return color_map + + @staticmethod + def relative_luminance(color: QColor) -> float: + """ + Calculate the relative luminance of a QColor according to WCAG 2.0 standards. + See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance. + + Args: + color(QColor): The color to calculate the relative luminance for. + + Returns: + float: The relative luminance of the color. + """ + r = color.red() / 255.0 + g = color.green() / 255.0 + b = color.blue() / 255.0 + + def adjust(c): + if c <= 0.03928: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + r = adjust(r) + g = adjust(g) + b = adjust(b) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + @staticmethod + def _tint_strength( + accent: QColor, background: QColor, min_tint: float = 0.06, max_tint: float = 0.18 + ) -> float: + """ + Calculate the tint strength based on the contrast between the accent and background colors. + min_tint and max_tint define the range of tint strength and are empirically chosen. + + Args: + accent(QColor): The accent color. + background(QColor): The background color. + min_tint(float): The minimum tint strength. + max_tint(float): The maximum tint strength. + + Returns: + float: The tint strength between 0 and 1. + """ + l_accent = Colors.relative_luminance(accent) + l_bg = Colors.relative_luminance(background) + + contrast = abs(l_accent - l_bg) + + # normalize contrast to a value between 0 and 1 + t = min(contrast / 0.9, 1.0) + return min_tint + t * (max_tint - min_tint) + + @staticmethod + def _blend(background: QColor, accent: QColor, t: float) -> QColor: + """ + Blend two colors based on a tint strength t. + """ + return QColor( + round(background.red() + (accent.red() - background.red()) * t), + round(background.green() + (accent.green() - background.green()) * t), + round(background.blue() + (accent.blue() - background.blue()) * t), + round(background.alpha() + (accent.alpha() - background.alpha()) * t), + ) + + @staticmethod + def subtle_background_color(accent: QColor, background: QColor) -> QColor: + """ + Generate a subtle, contrast-safe background color derived from an accent color. + + Args: + accent(QColor): The accent color. + background(QColor): The background color. + Returns: + QColor: The generated subtle background color. + """ + if not accent.isValid() or not background.isValid(): + return background + + tint = Colors._tint_strength(accent, background) + return Colors._blend(background, accent, tint) From dcfc5730526820e94e98c2bf4667abb424a8652a Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 26 Jan 2026 08:37:42 +0100 Subject: [PATCH 131/155] fix(FakeDevice): add _info dict --- bec_widgets/tests/utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index d9fd7d436..0b47f367e 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -25,6 +25,16 @@ def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORE "readOnly": False, "name": self.name, } + self._info = { + "signals": { + self.name: { + "kind_str": "hinted", + "component_name": self.name, + "obj_name": self.name, + "signal_class": "Signal", + } + } + } @property def readout_priority(self): From 99176198ee8ab35f53ac14c1777815f1d9083544 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 20 Jan 2026 19:14:55 +0100 Subject: [PATCH 132/155] fix: adjust ring progress bar to ads --- bec_widgets/cli/client.py | 234 ++--- .../advanced_dock_area/advanced_dock_area.py | 2 +- .../progress/ring_progress_bar/__init__.py | 1 - .../progress/ring_progress_bar/ring.py | 651 ++++++++++---- .../ring_progress_bar/ring_progress_bar.py | 832 +++++++----------- .../ring_progress_bar_plugin.py | 2 +- .../ring_progress_settings_cards.py | 509 +++++++++++ .../ring_progress_bar/ring_settings.ui | 235 +++++ .../widgets/progress_bar/ring_progress_bar.md | 51 +- .../test_user_interaction_e2e.py | 50 +- tests/unit_tests/test_ring_progress_bar.py | 526 ++++++----- .../unit_tests/test_ring_progress_bar_ring.py | 602 +++++++++++++ .../unit_tests/test_ring_progress_settings.py | 66 ++ 13 files changed, 2633 insertions(+), 1128 deletions(-) create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui create mode 100644 tests/unit_tests/test_ring_progress_bar_ring.py create mode 100644 tests/unit_tests/test_ring_progress_settings.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index c0c0bdf7f..7525a8e8f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -4713,29 +4713,6 @@ def detach(self): class Ring(RPCBase): - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. - """ - - @property - @rpc_call - def _rpc_id(self) -> "str": - """ - Get the RPC ID of the widget. - """ - - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. - """ - @rpc_call def set_value(self, value: "int | float"): """ @@ -4755,14 +4732,24 @@ def set_color(self, color: "str | tuple"): """ @rpc_call - def set_background(self, color: "str | tuple"): + def set_background(self, color: "str | tuple | QColor"): """ - Set the background color for the ring widget + Set the background color for the ring widget. The background color is only used when colors are not linked. Args: color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ + @rpc_call + def set_colors_linked(self, linked: "bool"): + """ + Set whether the colors are linked for the ring widget. + If colors are linked, changing the main color will also change the background color. + + Args: + linked(bool): Whether to link the colors for the ring widget + """ + @rpc_call def set_line_width(self, width: "int"): """ @@ -4785,14 +4772,16 @@ def set_min_max_values(self, min_value: "int | float", max_value: "int | float") @rpc_call def set_start_angle(self, start_angle: "int"): """ - Set the start angle for the ring widget + Set the start angle for the ring widget. Args: start_angle(int): Start angle for the ring widget in degrees """ @rpc_call - def set_update(self, mode: "Literal['manual', 'scan', 'device']", device: "str" = None): + def set_update( + self, mode: "Literal['manual', 'scan', 'device']", device: "str" = "", signal: "str" = "" + ): """ Set the update mode for the ring widget. Modes: @@ -4803,212 +4792,93 @@ def set_update(self, mode: "Literal['manual', 'scan', 'device']", device: "str" Args: mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device" device(str): Device name for the device readback mode, only used when mode is "device" + signal(str): Signal name for the device readback mode, only used when mode is "device" """ @rpc_call - def reset_connection(self): + def set_precision(self, precision: "int"): """ - Reset the connections for the ring widget. Disconnect the current slot and endpoint. - """ - - -class RingProgressBar(RPCBase): - """Show the progress of devices, scans or custom values in the form of ring progress bars.""" - - @rpc_call - def _get_all_rpc(self) -> "dict": - """ - Get all registered RPC objects. - """ - - @property - @rpc_call - def _rpc_id(self) -> "str": - """ - Get the RPC ID of the widget. - """ - - @property - @rpc_call - def _config_dict(self) -> "dict": - """ - Get the configuration of the widget. - - Returns: - dict: The configuration of the widget. - """ - - @property - @rpc_call - def rings(self) -> "list[Ring]": - """ - Returns a list of all rings in the progress bar. - """ - - @rpc_call - def update_config(self, config: "RingProgressBarConfig | dict"): - """ - Update the configuration of the widget. + Set the precision for the ring widget. Args: - config(SpiralProgressBarConfig|dict): Configuration to update. - """ - - @rpc_call - def add_ring(self, **kwargs) -> "Ring": + precision(int): Precision for the ring widget """ - Add a new progress bar. - Args: - **kwargs: Keyword arguments for the new progress bar. - - Returns: - Ring: Ring object. - """ - - @rpc_call - def remove_ring(self, index: "int"): - """ - Remove a progress bar by index. - - Args: - index(int): Index of the progress bar to remove. - """ +class RingProgressBar(RPCBase): @rpc_call - def set_precision(self, precision: "int", bar_index: "int | None" = None): + def remove(self): """ - Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars. - - Args: - precision(int): Precision for the progress bars. - bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set. + Cleanup the BECConnector """ @rpc_call - def set_min_max_values( - self, - min_values: "int | float | list[int | float]", - max_values: "int | float | list[int | float]", - ): + def attach(self): """ - Set the minimum and maximum values for the progress bars. - - Args: - min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar. - max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar. + None """ @rpc_call - def set_number_of_bars(self, num_bars: "int"): + def detach(self): """ - Set the number of progress bars to display. - - Args: - num_bars(int): Number of progress bars to display. + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + @rpc_timeout(None) @rpc_call - def set_value(self, values: "int | list", ring_index: "int" = None): + def screenshot(self, file_name: "str | None" = None): """ - Set the values for the progress bars. - - Args: - values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar. - ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set. - - Examples: - >>> SpiralProgressBar.set_value(50) - >>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner) - >>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar. + Take a screenshot of the dock area and save it to a file. """ + @property @rpc_call - def set_colors_from_map(self, colormap, color_format: "Literal['RGB', 'HEX']" = "RGB"): + def rings(self) -> list[bec_widgets.widgets.progress.ring_progress_bar.ring.Ring]: """ - Set the colors for the progress bars from a colormap. - - Args: - colormap(str): Name of the colormap. - color_format(Literal["RGB","HEX"]): Format of the returned colors ('RGB', 'HEX'). + None """ @rpc_call - def set_colors_directly( - self, colors: "list[str | tuple] | str | tuple", bar_index: "int" = None - ): + def add_ring( + self, config: dict | None = None + ) -> bec_widgets.widgets.progress.ring_progress_bar.ring.Ring: """ - Set the colors for the progress bars directly. + Add a new ring to the ring progress bar. + Optionally, a configuration dictionary can be provided but the ring + can also be configured later. The config dictionary must provide + the qproperties of the Qt Ring object. Args: - colors(list[str | tuple] | str | tuple): Color(s) for the progress bars. If multiple progress bars are displayed, provide a list of colors for each progress bar. - bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set. - """ - - @rpc_call - def set_line_widths(self, widths: "int | list[int]", bar_index: "int" = None): - """ - Set the line widths for the progress bars. + config(dict | None): Optional configuration dictionary for the ring. - Args: - widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar. - bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set. + Returns: + Ring: The newly added ring object. """ @rpc_call - def set_gap(self, gap: "int"): + def remove_ring(self, index: int | None = None): """ - Set the gap between the progress bars. - + Remove a ring from the ring progress bar. Args: - gap(int): Gap between the progress bars. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ @rpc_call - def set_diameter(self, diameter: "int"): + def set_gap(self, value: int): """ - Set the diameter of the widget. + Set the gap between rings. Args: - diameter(int): Diameter of the widget. + value(int): Gap value in pixels. """ @rpc_call - def reset_diameter(self): - """ - Reset the fixed size of the widget. + def set_center_label(self, text: str): """ - - @rpc_call - def enable_auto_updates(self, enable: "bool" = True): - """ - Enable or disable updates based on scan status. Overrides manual updates. - The behaviour of the whole progress bar widget will be driven by the scan queue status. + Set the center label text. Args: - enable(bool): True or False. - - Returns: - bool: True if scan segment updates are enabled. - """ - - @rpc_call - def attach(self): - """ - None - """ - - @rpc_call - def detach(self): - """ - Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. - """ - - @rpc_timeout(None) - @rpc_call - def screenshot(self, file_name: "str | None" = None): - """ - Take a screenshot of the dock area and save it to a file. + text(str): Text for the center label. """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index e9f85b9a8..143ab04e9 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -77,7 +77,7 @@ from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform from bec_widgets.widgets.plots.waveform.waveform import Waveform -from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.utility.logpanel import LogPanel diff --git a/bec_widgets/widgets/progress/ring_progress_bar/__init__.py b/bec_widgets/widgets/progress/ring_progress_bar/__init__.py index c20ea5599..e69de29bb 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/__init__.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/__init__.py @@ -1 +0,0 @@ -from .ring_progress_bar import RingProgressBar diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index 19b468112..a29641544 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -1,130 +1,88 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import TYPE_CHECKING, Callable, Literal from bec_lib.endpoints import EndpointInfo, MessageEndpoints -from pydantic import BaseModel, Field, field_validator -from pydantic_core import PydanticCustomError -from qtpy import QtGui -from qtpy.QtCore import QObject - -from bec_widgets.utils import BECConnector, ConnectionConfig - - -class ProgressbarConnections(BaseModel): - slot: Literal["on_scan_progress", "on_device_readback", None] = None - endpoint: EndpointInfo | str | None = None - model_config: dict = {"validate_assignment": True} - - @field_validator("endpoint") - @classmethod - def validate_endpoint(cls, v, values): - slot = values.data["slot"] - v = v.endpoint if isinstance(v, EndpointInfo) else v - if slot == "on_scan_progress": - if v != MessageEndpoints.scan_progress().endpoint: - raise PydanticCustomError( - "unsupported endpoint", - "For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'.", - {"wrong_value": v}, - ) - elif slot == "on_device_readback": - if not v.startswith(MessageEndpoints.device_readback("").endpoint): - raise PydanticCustomError( - "unsupported endpoint", - "For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'.", - {"wrong_value": v}, - ) - return v +from bec_lib.logger import bec_logger +from pydantic import Field +from qtpy import QtCore, QtGui +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig +from bec_widgets.utils.colors import Colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot + +logger = bec_logger.logger +if TYPE_CHECKING: + from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressContainerWidget, + ) class ProgressbarConfig(ConnectionConfig): - value: int | float | None = Field(0, description="Value for the progress bars.") - direction: int | None = Field( + value: int | float = Field(0, description="Value for the progress bars.") + direction: int = Field( -1, description="Direction of the progress bars. -1 for clockwise, 1 for counter-clockwise." ) - color: str | tuple | None = Field( + color: str | tuple = Field( (0, 159, 227, 255), description="Color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.", ) - background_color: str | tuple | None = Field( + background_color: str | tuple = Field( (200, 200, 200, 50), description="Background color for the progress bars. Can be tuple (R, G, B, A) or string HEX Code.", ) - index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.") - line_width: int | None = Field(10, description="Line widths for the progress bars.") - start_position: int | None = Field( + link_colors: bool = Field( + True, + description="Whether to link the background color to the main color. If True, changing the main color will also change the background color.", + ) + line_width: int = Field(20, description="Line widths for the progress bars.") + start_position: int = Field( 90, description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to " "the top of the ring.", ) - min_value: int | float | None = Field(0, description="Minimum value for the progress bars.") - max_value: int | float | None = Field(100, description="Maximum value for the progress bars.") - precision: int | None = Field(3, description="Precision for the progress bars.") - update_behaviour: Literal["manual", "auto"] | None = Field( - "auto", description="Update behaviour for the progress bars." + min_value: int | float = Field(0, description="Minimum value for the progress bars.") + max_value: int | float = Field(100, description="Maximum value for the progress bars.") + precision: int = Field(3, description="Precision for the progress bars.") + mode: Literal["manual", "scan", "device"] = Field( + "manual", description="Update mode for the progress bars." ) - connections: ProgressbarConnections | None = Field( - default_factory=ProgressbarConnections, description="Connections for the progress bars." + device: str | None = Field( + None, + description="Device name for the device readback mode, only used when mode is 'device'.", ) - - -class RingConfig(ProgressbarConfig): - index: int | None = Field(0, description="Index of the progress bar. 0 is outer ring.") - start_position: int | None = Field( - 90, - description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to " - "the top of the ring.", + signal: str | None = Field( + None, + description="Signal name for the device readback mode, only used when mode is 'device'.", ) -class Ring(BECConnector, QObject): +class Ring(BECConnector, QWidget): USER_ACCESS = [ - "_get_all_rpc", - "_rpc_id", - "_config_dict", "set_value", "set_color", "set_background", + "set_colors_linked", "set_line_width", "set_min_max_values", "set_start_angle", "set_update", - "reset_connection", + "set_precision", ] - - def __init__( - self, - parent=None, - config: RingConfig | dict | None = None, - client=None, - gui_id: Optional[str] = None, - **kwargs, - ): - if config is None: - config = RingConfig(widget_class=self.__class__.__name__) - self.config = config - else: - if isinstance(config, dict): - config = RingConfig(**config) - self.config = config - super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) - - self.parent_progress_widget = parent - - self.color = None - self.background_color = None - self.start_position = None - self.config = config + RPC = True + + def __init__(self, parent: RingProgressContainerWidget | None = None, client=None, **kwargs): + self.progress_container = parent + self.config: ProgressbarConfig = ProgressbarConfig(widget_class=self.__class__.__name__) # type: ignore + super().__init__(parent=parent, client=client, config=self.config, **kwargs) + self._color: QColor = self.convert_color(self.config.color) + self._background_color: QColor = self.convert_color(self.config.background_color) + self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None self.RID = None - self._init_config_params() - - def _init_config_params(self): - self.color = self.convert_color(self.config.color) - self.background_color = self.convert_color(self.config.background_color) + self._gap = 5 self.set_start_angle(self.config.start_position) - if self.config.connections: - self.set_connections(self.config.connections.slot, self.config.connections.endpoint) def set_value(self, value: int | float): """ @@ -133,11 +91,7 @@ def set_value(self, value: int | float): Args: value(int | float): Value for the ring widget """ - self.config.value = round( - float(max(self.config.min_value, min(self.config.max_value, value))), - self.config.precision, - ) - self.parent_progress_widget.update() + self.value = value def set_color(self, color: str | tuple): """ @@ -146,20 +100,53 @@ def set_color(self, color: str | tuple): Args: color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ - self.config.color = color - self.color = self.convert_color(color) - self.parent_progress_widget.update() + self._color = self.convert_color(color) + self.config.color = self._color.name() + + # Automatically set background color + if self.config.link_colors: + self._auto_set_background_color() - def set_background(self, color: str | tuple): + self.update() + + def set_background(self, color: str | tuple | QColor): """ - Set the background color for the ring widget + Set the background color for the ring widget. The background color is only used when colors are not linked. Args: color(str | tuple): Background color for the ring widget. Can be HEX code or tuple (R, G, B, A). """ - self.config.background_color = color - self.color = self.convert_color(color) - self.parent_progress_widget.update() + # Only allow manual background color changes when colors are not linked + if self.config.link_colors: + return + + self._background_color = self.convert_color(color) + self.config.background_color = self._background_color.name() + self.update() + + def _auto_set_background_color(self): + """ + Automatically set the background color based on the main color and the current theme. + """ + palette = self.palette() + bg = palette.color(QtGui.QPalette.ColorRole.Window) + bg_color = Colors.subtle_background_color(self._color, bg) + self.config.background_color = bg_color.name() + self._background_color = bg_color + self.update() + + def set_colors_linked(self, linked: bool): + """ + Set whether the colors are linked for the ring widget. + If colors are linked, changing the main color will also change the background color. + + Args: + linked(bool): Whether to link the colors for the ring widget + """ + self.config.link_colors = linked + if linked: + self._auto_set_background_color() + self.update() def set_line_width(self, width: int): """ @@ -169,7 +156,7 @@ def set_line_width(self, width: int): width(int): Line width for the ring widget """ self.config.line_width = width - self.parent_progress_widget.update() + self.update() def set_min_max_values(self, min_value: int | float, max_value: int | float): """ @@ -181,35 +168,21 @@ def set_min_max_values(self, min_value: int | float, max_value: int | float): """ self.config.min_value = min_value self.config.max_value = max_value - self.parent_progress_widget.update() + self.update() def set_start_angle(self, start_angle: int): """ - Set the start angle for the ring widget + Set the start angle for the ring widget. Args: start_angle(int): Start angle for the ring widget in degrees """ self.config.start_position = start_angle - self.start_position = start_angle * 16 - self.parent_progress_widget.update() + self.update() - @staticmethod - def convert_color(color): - """ - Convert the color to QColor - - Args: - color(str | tuple): Color for the ring widget. Can be HEX code or tuple (R, G, B, A). - """ - converted_color = None - if isinstance(color, str): - converted_color = QtGui.QColor(color) - elif isinstance(color, tuple): - converted_color = QtGui.QColor(*color) - return converted_color - - def set_update(self, mode: Literal["manual", "scan", "device"], device: str = None): + def set_update( + self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = "" + ): """ Set the update mode for the ring widget. Modes: @@ -220,47 +193,167 @@ def set_update(self, mode: Literal["manual", "scan", "device"], device: str = No Args: mode(str): Update mode for the ring widget. Can be "manual", "scan" or "device" device(str): Device name for the device readback mode, only used when mode is "device" + signal(str): Signal name for the device readback mode, only used when mode is "device" """ - if mode == "manual": - if self.config.connections.slot is not None: - self.bec_dispatcher.disconnect_slot( - getattr(self, self.config.connections.slot), self.config.connections.endpoint + + match mode: + case "manual": + if self.config.mode == "manual": + return + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "manual" + self.registered_slot = None + case "scan": + if self.config.mode == "scan": + return + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "scan" + self.bec_dispatcher.connect_slot( + self.on_scan_progress, MessageEndpoints.scan_progress() ) - self.config.connections.slot = None - self.config.connections.endpoint = None - elif mode == "scan": - self.set_connections("on_scan_progress", MessageEndpoints.scan_progress()) - elif mode == "device": - self.set_connections("on_device_readback", MessageEndpoints.device_readback(device)) + self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress()) + case "device": + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.config.mode = "device" + if device == "": + self.registered_slot = None + return + self.config.device = device + # self.config.signal = self._get_signal_from_device(device, signal) + signal = self._update_device_connection(device, signal) + self.config.signal = signal + + case _: + raise ValueError(f"Unsupported mode: {mode}") + + def set_precision(self, precision: int): + """ + Set the precision for the ring widget. - self.parent_progress_widget.enable_auto_updates(False) + Args: + precision(int): Precision for the ring widget + """ + self.config.precision = precision + self.update() - def set_connections(self, slot: str, endpoint: str | EndpointInfo): + def set_direction(self, direction: int): """ - Set the connections for the ring widget + Set the direction for the ring widget. Args: - slot(str): Slot for the ring widget update. Can be "on_scan_progress" or "on_device_readback". - endpoint(str | EndpointInfo): Endpoint for the ring widget update. Endpoint has to match the slot type. + direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise. """ - if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot: - return - if self.config.connections.slot is not None: - self.bec_dispatcher.disconnect_slot( - getattr(self, self.config.connections.slot), self.config.connections.endpoint - ) - self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint) - self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint) + self.config.direction = direction + self.update() + + def _get_signals_for_device(self, device: str) -> dict[str, list[str]]: + """ + Get the signals for the device. - def reset_connection(self): + Args: + device(str): Device name for the device + + Returns: + dict[str, list[str]]: Dictionary with the signals for the device """ - Reset the connections for the ring widget. Disconnect the current slot and endpoint. + dm = self.bec_dispatcher.client.device_manager + if not dm: + raise ValueError("Device manager is not available in the BEC client.") + dev_obj = dm.devices.get(device) + if dev_obj is None: + raise ValueError(f"Device '{device}' not found in device manager.") + + progress_signals = [ + obj["component_name"] + for obj in dev_obj._info["signals"].values() + if obj["signal_class"] == "ProgressSignal" + ] + hinted_signals = [ + obj["obj_name"] + for obj in dev_obj._info["signals"].values() + if obj["kind_str"] == "hinted" + and obj["signal_class"] + not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"] + ] + + normal_signals = [ + obj["component_name"] + for obj in dev_obj._info["signals"].values() + if obj["kind_str"] == "normal" + ] + return { + "progress_signals": progress_signals, + "hinted_signals": hinted_signals, + "normal_signals": normal_signals, + } + + def _update_device_connection(self, device: str, signal: str | None) -> str: """ - self.bec_dispatcher.disconnect_slot( - self.config.connections.slot, self.config.connections.endpoint - ) - self.config.connections = ProgressbarConnections() + Update the device connection for the ring widget. + + In general, we support two modes here: + - If signal is provided, we use that directly. + - If signal is not provided, we try to get the signal from the device manager. + We first check for progress signals, then for hinted signals, and finally for normal signals. + + Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints. + + Args: + device(str): Device name for the device mode + signal(str): Signal name for the device mode + Returns: + str: The selected signal name for the device mode + """ + logger.info(f"Updating device connection for device '{device}' and signal '{signal}'") + dm = self.bec_dispatcher.client.device_manager + if not dm: + raise ValueError("Device manager is not available in the BEC client.") + dev_obj = dm.devices.get(device) + if dev_obj is None: + return "" + + signals = self._get_signals_for_device(device) + progress_signals = signals["progress_signals"] + hinted_signals = signals["hinted_signals"] + normal_signals = signals["normal_signals"] + + if not signal: + # If signal is not provided, we try to get it from the device manager + if len(progress_signals) > 0: + signal = progress_signals[0] + logger.info( + f"Using progress signal '{signal}' for device '{device}' in ring progress bar." + ) + elif len(hinted_signals) > 0: + signal = hinted_signals[0] + logger.info( + f"Using hinted signal '{signal}' for device '{device}' in ring progress bar." + ) + elif len(normal_signals) > 0: + signal = normal_signals[0] + logger.info( + f"Using normal signal '{signal}' for device '{device}' in ring progress bar." + ) + else: + logger.warning(f"No signals found for device '{device}' in ring progress bar.") + return "" + + if signal in progress_signals: + endpoint = MessageEndpoints.device_progress(device) + self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint) + self.registered_slot = (self.on_device_progress, endpoint) + return signal + if signal in hinted_signals or signal in normal_signals: + endpoint = MessageEndpoints.device_readback(device) + self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint) + self.registered_slot = (self.on_device_readback, endpoint) + return signal + + @SafeSlot(dict, dict) def on_scan_progress(self, msg, meta): """ Update the ring widget with the scan progress. @@ -273,8 +366,9 @@ def on_scan_progress(self, msg, meta): if current_RID != self.RID: self.set_min_max_values(0, msg.get("max_value", 100)) self.set_value(msg.get("value", 0)) - self.parent_progress_widget.update() + self.update() + @SafeSlot(dict, dict) def on_device_readback(self, msg, meta): """ Update the ring widget with the device readback. @@ -283,11 +377,242 @@ def on_device_readback(self, msg, meta): msg(dict): Message with the device readback meta(dict): Metadata for the message """ - if isinstance(self.config.connections.endpoint, EndpointInfo): - endpoint = self.config.connections.endpoint.endpoint - else: - endpoint = self.config.connections.endpoint - device = endpoint.split("/")[-1] - value = msg.get("signals").get(device).get("value") + device = self.config.device + if device is None: + return + signal = self.config.signal or device + value = msg.get("signals", {}).get(signal, {}).get("value", None) + if value is None: + return + self.set_value(value) + self.update() + + @SafeSlot(dict, dict) + def on_device_progress(self, msg, meta): + """ + Update the ring widget with the device progress. + + Args: + msg(dict): Message with the device progress + meta(dict): Metadata for the message + """ + device = self.config.device + if device is None: + return + max_val = msg.get("max_value", 100) + self.set_min_max_values(0, max_val) + value = msg.get("value", 0) + if msg.get("done"): + value = max_val self.set_value(value) - self.parent_progress_widget.update() + self.update() + + def paintEvent(self, event): + if not self.progress_container: + return + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + size = min(self.width(), self.height()) + + # Center the ring + x_offset = (self.width() - size) // 2 + y_offset = (self.height() - size) // 2 + + max_ring_size = self.progress_container.get_max_ring_size() + + rect = QtCore.QRect(x_offset, y_offset, size, size) + rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size) + + # Background arc + painter.setPen( + QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine) + ) + + gap: int = self.gap # type: ignore + + # Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16. + start_position: float = self.config.start_position * 16 # type: ignore + + adjusted_rect = QtCore.QRect( + rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap + ) + painter.drawArc(adjusted_rect, start_position, 360 * 16) + + # Foreground arc + pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine) + pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + proportion = (self.config.value - self.config.min_value) / ( + (self.config.max_value - self.config.min_value) + 1e-3 + ) + angle = int(proportion * 360 * 16 * self.config.direction) + painter.drawArc(adjusted_rect, start_position, angle) + painter.end() + + def convert_color(self, color: str | tuple | QColor) -> QColor: + """ + Convert the color to QColor + + Args: + color(str | tuple | QColor): Color for the ring widget. Can be HEX code or tuple (R, G, B, A) or QColor. + """ + + if isinstance(color, QColor): + return color + if isinstance(color, str): + return QtGui.QColor(color) + if isinstance(color, (tuple, list)): + return QtGui.QColor(*color) + raise ValueError(f"Unsupported color format: {color}") + + def cleanup(self): + """ + Cleanup the ring widget. + Disconnect any registered slots. + """ + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.registered_slot = None + + ############################################### + ####### QProperties ########################### + ############################################### + + @SafeProperty(int) + def gap(self) -> int: + return self._gap + + @gap.setter + def gap(self, value: int): + self._gap = value + self.update() + + @SafeProperty(bool) + def link_colors(self) -> bool: + return self.config.link_colors + + @link_colors.setter + def link_colors(self, value: bool): + logger.info(f"Setting link_colors to {value}") + self.set_colors_linked(value) + + @SafeProperty(QColor) + def color(self) -> QColor: + return self._color + + @color.setter + def color(self, value: QColor): + self.set_color(value) + + @SafeProperty(QColor) + def background_color(self) -> QColor: + return self._background_color + + @background_color.setter + def background_color(self, value: QColor): + self.set_background(value) + + @SafeProperty(float) + def value(self) -> float: + return self.config.value + + @value.setter + def value(self, value: float): + self.config.value = round( + float(max(self.config.min_value, min(self.config.max_value, value))), + self.config.precision, + ) + self.update() + + @SafeProperty(float) + def min_value(self) -> float: + return self.config.min_value + + @min_value.setter + def min_value(self, value: float): + self.config.min_value = value + self.update() + + @SafeProperty(float) + def max_value(self) -> float: + return self.config.max_value + + @max_value.setter + def max_value(self, value: float): + self.config.max_value = value + self.update() + + @SafeProperty(str) + def mode(self) -> str: + return self.config.mode + + @mode.setter + def mode(self, value: str): + self.set_update(value) + + @SafeProperty(str) + def device(self) -> str: + return self.config.device or "" + + @device.setter + def device(self, value: str): + self.config.device = value + + @SafeProperty(str) + def signal(self) -> str: + return self.config.signal or "" + + @signal.setter + def signal(self, value: str): + self.config.signal = value + + @SafeProperty(int) + def line_width(self) -> int: + return self.config.line_width + + @line_width.setter + def line_width(self, value: int): + self.config.line_width = value + self.update() + + @SafeProperty(int) + def start_position(self) -> int: + return self.config.start_position + + @start_position.setter + def start_position(self, value: int): + self.config.start_position = value + self.update() + + @SafeProperty(int) + def precision(self) -> int: + return self.config.precision + + @precision.setter + def precision(self, value: int): + self.config.precision = value + self.update() + + @SafeProperty(int) + def direction(self) -> int: + return self.config.direction + + @direction.setter + def direction(self, value: int): + self.config.direction = value + self.update() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + ring = Ring() + ring.export_settings() + ring.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index 6385e3875..0a6c8dd8b 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -1,348 +1,154 @@ -from __future__ import annotations - -from typing import Literal, Optional +import json +from typing import Literal import pyqtgraph as pg -from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger -from pydantic import Field, field_validator -from pydantic_core import PydanticCustomError -from qtpy import QtCore, QtGui -from qtpy.QtCore import QSize, Slot -from qtpy.QtWidgets import QSizePolicy, QWidget +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget -from bec_widgets.utils import Colors, ConnectionConfig, EntryValidator +from bec_widgets.utils import Colors from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring, RingConfig +from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.settings_dialog import SettingsDialog +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings logger = bec_logger.logger -class RingProgressBarConfig(ConnectionConfig): - color_map: Optional[str] = Field( - "plasma", description="Color scheme for the progress bars.", validate_default=True - ) - min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.") - max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.") - num_bars: int = Field(1, description="Number of progress bars to display.") - gap: int | None = Field(20, description="Gap between progress bars.") - auto_updates: bool | None = Field( - True, description="Enable or disable updates based on scan queue status." - ) - rings: list[RingConfig] | None = Field([], description="List of ring configurations.") - - @field_validator("num_bars") - @classmethod - def validate_num_bars(cls, v, values): - min_number_of_bars = values.data.get("min_number_of_bars", None) - max_number_of_bars = values.data.get("max_number_of_bars", None) - if min_number_of_bars is not None and max_number_of_bars is not None: - logger.info( - f"Number of bars adjusted to be between defined min:{min_number_of_bars} and max:{max_number_of_bars} number of bars." - ) - v = max(min_number_of_bars, min(v, max_number_of_bars)) - return v - - @field_validator("rings") - @classmethod - def validate_rings(cls, v, values): - if v is not None and v is not []: - num_bars = values.data.get("num_bars", None) - if len(v) != num_bars: - raise PydanticCustomError( - "different number of configs", - f"Length of rings configuration ({len(v)}) does not match the number of bars ({num_bars}).", - {"wrong_value": len(v)}, - ) - indices = [ring.index for ring in v] - if sorted(indices) != list(range(len(indices))): - raise PydanticCustomError( - "wrong indices", - f"Indices of ring configurations must be unique and in order from 0 to num_bars {num_bars}.", - {"wrong_value": indices}, - ) - return v - - _validate_colormap = field_validator("color_map")(Colors.validate_color_map) - - -class RingProgressBar(BECWidget, QWidget): +class RingProgressContainerWidget(QWidget): """ - Show the progress of devices, scans or custom values in the form of ring progress bars. + A container widget for the Ring Progress Bar widget. + It holds the rings and manages their layout and painting. """ - PLUGIN = True - ICON_NAME = "track_changes" - USER_ACCESS = [ - "_get_all_rpc", - "_rpc_id", - "_config_dict", - "rings", - "update_config", - "add_ring", - "remove_ring", - "set_precision", - "set_min_max_values", - "set_number_of_bars", - "set_value", - "set_colors_from_map", - "set_colors_directly", - "set_line_widths", - "set_gap", - "set_diameter", - "reset_diameter", - "enable_auto_updates", - "attach", - "detach", - "screenshot", - ] - - def __init__( - self, - parent=None, - config: RingProgressBarConfig | dict | None = None, - client=None, - gui_id: str | None = None, - num_bars: int | None = None, - **kwargs, - ): - if config is None: - config = RingProgressBarConfig(widget_class=self.__class__.__name__) - self.config = config - else: - if isinstance(config, dict): - config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__) - self.config = config - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - - self.get_bec_shortcuts() - self.entry_validator = EntryValidator(self.dev) - - self.RID = None - - # For updating bar behaviour - self._auto_updates = True - self._rings = [] - - if num_bars is not None: - self.config.num_bars = max( - self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars) - ) + def __init__(self, parent: QWidget | None = None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.rings: list[Ring] = [] + self.gap = 20 # Gap between rings + self.color_map: str = "turbo" + self.setLayout(QHBoxLayout()) self.initialize_bars() - - self.enable_auto_updates(self.config.auto_updates) + self.initialize_center_label() @property - def rings(self) -> list[Ring]: - """Returns a list of all rings in the progress bar.""" - return self._rings - - @rings.setter - def rings(self, value: list[Ring]): - self._rings = value - - def update_config(self, config: RingProgressBarConfig | dict): - """ - Update the configuration of the widget. - - Args: - config(SpiralProgressBarConfig|dict): Configuration to update. - """ - if isinstance(config, dict): - config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__) - self.config = config - self.clear_all() + def num_bars(self) -> int: + return len(self.rings) def initialize_bars(self): """ Initialize the progress bars. """ - start_positions = [90 * 16] * self.config.num_bars - directions = [-1] * self.config.num_bars + for _ in range(self.num_bars): + self.add_ring() - self.config.rings = [ - RingConfig( - widget_class="Ring", - index=i, - start_positions=start_positions[i], - directions=directions[i], - ) - for i in range(self.config.num_bars) - ] - self._rings = [Ring(parent=self, config=config) for config in self.config.rings] - - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - - min_size = self._calculate_minimum_size() - self.setMinimumSize(min_size) - # Set outer ring to listen to scan progress - self.rings[0].set_update(mode="scan") - self.update() + if self.color_map: + self.set_colors_from_map(self.color_map) - def add_ring(self, **kwargs) -> Ring: + def add_ring(self, config: dict | None = None) -> Ring: """ - Add a new progress bar. + Add a new ring to the container. Args: - **kwargs: Keyword arguments for the new progress bar. + config(dict | None): Optional configuration dictionary for the ring. Returns: - Ring: Ring object. - """ - if self.config.num_bars < self.config.max_number_of_bars: - ring_index = self.config.num_bars - ring_config = RingConfig( - widget_class="Ring", - index=ring_index, - start_positions=90 * 16, - directions=-1, - **kwargs, - ) - ring = Ring(parent=self, config=ring_config) - self.config.num_bars += 1 - self._rings.append(ring) - self.config.rings.append(ring.config) - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - base_line_width = self._rings[ring.config.index].config.line_width - self.set_line_widths(base_line_width, ring.config.index) - self.update() - return ring + Ring: The newly added ring object. + """ + ring = Ring(parent=self) + ring.setGeometry(self.rect()) + ring.gap = self.gap * len(self.rings) + ring.set_value(0) + self.rings.append(ring) + if config: + # We have to first get the link_colors property before loading the settings + # While this is an ugly hack, we do not have control over the order of properties + # being set when loading. + ring.link_colors = config.pop("link_colors", True) + ring.load_settings(config) + if self.color_map: + self.set_colors_from_map(self.color_map) + ring.show() + ring.raise_() + self.update() + return ring - def remove_ring(self, index: int): + def remove_ring(self, index: int | None = None): """ - Remove a progress bar by index. + Remove a ring from the container. Args: - index(int): Index of the progress bar to remove. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ - ring = self._find_ring_by_index(index) - self._cleanup_ring(ring) - self.update() - - def _cleanup_ring(self, ring: Ring) -> None: - ring.reset_connection() - self._rings.remove(ring) - self.config.rings.remove(ring.config) - self.config.num_bars -= 1 - self._reindex_rings() - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - # Remove ring from rpc, afterwards call close event. - ring.rpc_register.remove_rpc(ring) + if self.num_bars == 0: + return + if index is None: + index = self.num_bars - 1 + index = self._validate_index(index) + ring = self.rings[index] + ring.cleanup() + ring.close() ring.deleteLater() - # del ring + self.rings.pop(index) + # Update gaps for remaining rings + for i, r in enumerate(self.rings): + r.gap = self.gap * i + self.update() - def _reindex_rings(self): + def initialize_center_label(self): """ - Reindex the progress bars. + Initialize the center label. """ - for i, ring in enumerate(self._rings): - ring.config.index = i + layout = self.layout() + layout.setContentsMargins(0, 0, 0, 0) - def set_precision(self, precision: int, bar_index: int | None = None): - """ - Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars. + self.center_label = QLabel("", parent=self) + self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.center_label) - Args: - precision(int): Precision for the progress bars. - bar_index(int): Index of the progress bar to set the precision for. If provided, only a single precision can be set. + def _calculate_minimum_size(self): """ - if bar_index is not None: - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - ring.config.precision = precision - else: - for ring in self._rings: - ring.config.precision = precision - self.update() - - def set_min_max_values( - self, - min_values: int | float | list[int | float], - max_values: int | float | list[int | float], - ): + Calculate the minimum size of the widget. """ - Set the minimum and maximum values for the progress bars. + if not self.rings: + return QSize(10, 10) + ring_widths = self.get_ring_line_widths() + total_width = sum(ring_widths) + self.gap * (self.num_bars - 1) + diameter = max(total_width * 2, 50) - Args: - min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar. - max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar. - """ - if isinstance(min_values, (int, float)): - min_values = [min_values] - if isinstance(max_values, (int, float)): - max_values = [max_values] - min_values = self._adjust_list_to_bars(min_values) - max_values = self._adjust_list_to_bars(max_values) - for ring, min_value, max_value in zip(self._rings, min_values, max_values): - ring.set_min_max_values(min_value, max_value) - self.update() + return QSize(diameter, diameter) - def set_number_of_bars(self, num_bars: int): + def get_ring_line_widths(self): """ - Set the number of progress bars to display. - - Args: - num_bars(int): Number of progress bars to display. + Get the line widths of the rings. """ - num_bars = max( - self.config.min_number_of_bars, min(num_bars, self.config.max_number_of_bars) - ) - current_num_bars = self.config.num_bars - - if num_bars > current_num_bars: - for i in range(current_num_bars, num_bars): - new_ring_config = RingConfig( - widget_class="Ring", index=i, start_positions=90 * 16, directions=-1 - ) - self.config.rings.append(new_ring_config) - new_ring = Ring(parent=self, config=new_ring_config) - self._rings.append(new_ring) - - elif num_bars < current_num_bars: - for i in range(current_num_bars - 1, num_bars - 1, -1): - self.remove_ring(i) + if not self.rings: + return [10] + ring_widths = [ring.config.line_width for ring in self.rings] + return ring_widths - self.config.num_bars = num_bars - - if self.config.color_map: - self.set_colors_from_map(self.config.color_map) - - base_line_width = self._rings[0].config.line_width - self.set_line_widths(base_line_width) + def get_max_ring_size(self) -> int: + """ + Get the size of the rings. + """ + if not self.rings: + return 10 + ring_widths = self.get_ring_line_widths() + return max(ring_widths) - self.update() + def sizeHint(self): + min_size = self._calculate_minimum_size() + return min_size - def set_value(self, values: int | list, ring_index: int = None): + def resizeEvent(self, event): """ - Set the values for the progress bars. - - Args: - values(int | tuple): Value(s) for the progress bars. If multiple progress bars are displayed, provide a tuple of values for each progress bar. - ring_index(int): Index of the progress bar to set the value for. If provided, only a single value can be set. - - Examples: - >>> SpiralProgressBar.set_value(50) - >>> SpiralProgressBar.set_value([30, 40, 50]) # (outer, middle, inner) - >>> SpiralProgressBar.set_value(60, bar_index=1) # Set the value for the middle progress bar. - """ - if ring_index is not None: - ring = self._find_ring_by_index(ring_index) - if isinstance(values, list): - values = values[0] - logger.warning( - f"Warning: Only a single value can be set for a single progress bar. Using the first value in the list {values}" - ) - ring.set_value(values) - else: - if isinstance(values, int): - values = [values] - values = self._adjust_list_to_bars(values) - for ring, value in zip(self._rings, values): - ring.set_value(value) - self.update() + Handle resize events to update ring geometries. + """ + super().resizeEvent(event) + for ring in self.rings: + ring.setGeometry(self.rect()) def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"): """ @@ -356,12 +162,14 @@ def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "R raise ValueError( f"Colormap '{colormap}' not found in the current installation of pyqtgraph" ) - colors = Colors.golden_angle_color(colormap, self.config.num_bars, color_format) + colors = Colors.golden_angle_color(colormap, self.num_bars, color_format) self.set_colors_directly(colors) - self.config.color_map = colormap + self.color_map = colormap self.update() - def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index: int = None): + def set_colors_directly( + self, colors: list[str | tuple] | str | tuple, bar_index: int | None = None + ): """ Set the colors for the progress bars directly. @@ -370,281 +178,275 @@ def set_colors_directly(self, colors: list[str | tuple] | str | tuple, bar_index bar_index(int): Index of the progress bar to set the color for. If provided, only a single color can be set. """ if bar_index is not None and isinstance(colors, (str, tuple)): - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - ring.set_color(colors) + bar_index = self._validate_index(bar_index) + self.rings[bar_index].set_color(colors) else: if isinstance(colors, (str, tuple)): colors = [colors] colors = self._adjust_list_to_bars(colors) - for ring, color in zip(self._rings, colors): + for ring, color in zip(self.rings, colors): ring.set_color(color) self.update() - def set_line_widths(self, widths: int | list[int], bar_index: int = None): + def _adjust_list_to_bars(self, items: list) -> list: """ - Set the line widths for the progress bars. + Utility method to adjust the list of parameters to match the number of progress bars. Args: - widths(int | list[int]): Line width(s) for the progress bars. If multiple progress bars are displayed, provide a list of line widths for each progress bar. - bar_index(int): Index of the progress bar to set the line width for. If provided, only a single line width can be set. - """ - if bar_index is not None: - bar_index = self._bar_index_check(bar_index) - ring = self._find_ring_by_index(bar_index) - if isinstance(widths, list): - widths = widths[0] - logger.warning( - f"Warning: Only a single line width can be set for a single progress bar. Using the first value in the list {widths}" - ) - ring.set_line_width(widths) - else: - if isinstance(widths, int): - widths = [widths] - widths = self._adjust_list_to_bars(widths) - self.config.gap = max(widths) * 2 - for ring, width in zip(self._rings, widths): - ring.set_line_width(width) - min_size = self._calculate_minimum_size() - self.setMinimumSize(min_size) - self.update() - - def set_gap(self, gap: int): - """ - Set the gap between the progress bars. + items(list): List of parameters for the progress bars. - Args: - gap(int): Gap between the progress bars. + Returns: + list: List of parameters for the progress bars. """ - self.config.gap = gap - self.update() + if items is None: + raise ValueError( + "Items cannot be None. Please provide a list for parameters for the progress bars." + ) + if not isinstance(items, list): + items = [items] + if len(items) < self.num_bars: + last_item = items[-1] + items.extend([last_item] * (self.num_bars - len(items))) + elif len(items) > self.num_bars: + items = items[: self.num_bars] + return items - def set_diameter(self, diameter: int): + def _validate_index(self, index: int) -> int: """ - Set the diameter of the widget. + Check if the provided index is valid for the number of bars. Args: - diameter(int): Diameter of the widget. + index(int): Index to check. + Returns: + int: Validated index. """ - size = QSize(diameter, diameter) - self.resize(size) - self.setFixedSize(size) + try: + self.rings[index] + except IndexError: + raise IndexError(f"Index {index} is out of range for {self.num_bars} rings.") + return index - def _find_ring_by_index(self, index: int) -> Ring: + def clear_all(self): + """ + Clear all rings from the widget. """ - Find the ring by index. + for ring in self.rings: + ring.close() + ring.deleteLater() + self.rings = [] + self.update() - Args: - index(int): Index of the ring. - Returns: - Ring: Ring object. - """ - for ring in self._rings: - if ring.config.index == index: - return ring - raise ValueError(f"Ring with index {index} not found.") +class RingProgressBar(BECWidget, QWidget): + ICON_NAME = "track_changes" + PLUGIN = True + RPC = True - def enable_auto_updates(self, enable: bool = True): - """ - Enable or disable updates based on scan status. Overrides manual updates. - The behaviour of the whole progress bar widget will be driven by the scan queue status. + USER_ACCESS = [ + *BECWidget.USER_ACCESS, + "screenshot", + "rings", + "add_ring", + "remove_ring", + "set_gap", + "set_center_label", + ] - Args: - enable(bool): True or False. + def __init__(self, parent: QWidget | None = None, client=None, **kwargs): + super().__init__(parent=parent, client=client, theme_update=True, **kwargs) - Returns: - bool: True if scan segment updates are enabled. - """ + self.setWindowTitle("Ring Progress Bar") - self._auto_updates = enable - if enable is True: - self.bec_dispatcher.connect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - else: - self.bec_dispatcher.disconnect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - return self._auto_updates + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) - @Slot(dict, dict) - def on_scan_queue_status(self, msg, meta): - """ - Slot to handle scan queue status messages. Decides what update to perform based on the scan queue status. + self.setLayout(self.layout) - Args: - msg(dict): Message from the BEC. - meta(dict): Metadata from the BEC. - """ - primary_queue = msg.get("queue").get("primary") - info = primary_queue.get("info", None) + self.toolbar = ModularToolBar(self) + self._init_toolbar() + self.layout.addWidget(self.toolbar) - if not info: - return - active_request_block = info[0].get("active_request_block", None) - if not active_request_block: - return - report_instructions = active_request_block.get("report_instructions", None) - if not report_instructions: - return + # Placeholder for the actual ring progress bar widget + self.ring_progress_bar = RingProgressContainerWidget(self) + self.layout.addWidget(self.ring_progress_bar) + + self.settings_dialog = None + + self.toolbar.show_bundles(["rpb_settings"]) + + def apply_theme(self, theme: str): + super().apply_theme(theme) + if self.ring_progress_bar.color_map: + self.ring_progress_bar.set_colors_from_map(self.ring_progress_bar.color_map) + + def _init_toolbar(self): + settings_action = MaterialIconAction( + icon_name="settings", + tooltip="Show Ring Progress Bar Settings", + checkable=True, + parent=self, + ) + self.toolbar.add_action("rpb_settings", settings_action) + settings_action.action.triggered.connect(self._open_settings_dialog) + + def _open_settings_dialog(self): + """ " + Open the settings dialog for the ring progress bar. + """ + settings_action = self.toolbar.components.get_action("rpb_settings").action + if self.settings_dialog is None or not self.settings_dialog.isVisible(): + settings = RingSettings(parent=self, target_widget=self, popup=True) + self.settings_dialog = SettingsDialog( + self, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + self.settings_dialog.resize(900, 500) + self.settings_dialog.finished.connect(self._settings_dialog_closed) + self.settings_dialog.show() - instruction_type = list(report_instructions[0].keys())[0] - if instruction_type == "scan_progress": - self._hook_scan_progress(ring_index=0) - elif instruction_type == "readback": - devices = report_instructions[0].get("readback").get("devices") - start = report_instructions[0].get("readback").get("start") - end = report_instructions[0].get("readback").get("end") - if self.config.num_bars != len(devices): - self.set_number_of_bars(len(devices)) - for index, device in enumerate(devices): - self._hook_readback(index, device, start[index], end[index]) + settings_action.setChecked(True) else: - logger.error(f"{instruction_type} not supported yet.") + # Dialog is already open, raise it + self.settings_dialog.raise_() + self.settings_dialog.activateWindow() + settings_action.setChecked(True) - def _hook_scan_progress(self, ring_index: int | None = None): + def _settings_dialog_closed(self): """ - Hook the scan progress to the progress bars. + Handle the settings dialog being closed. + """ + settings_action = self.toolbar.components.get_action("rpb_settings").action + settings_action.setChecked(False) + self.settings_dialog = None - Args: - ring_index(int): Index of the progress bar to hook the scan progress to. + ################################################# + ###### RPC User Access Methods ################## + ################################################# + + def add_ring(self, config: dict | None = None) -> Ring: """ - if ring_index is not None: - ring = self._find_ring_by_index(ring_index) - else: - ring = self._rings[0] + Add a new ring to the ring progress bar. + Optionally, a configuration dictionary can be provided but the ring + can also be configured later. The config dictionary must provide + the qproperties of the Qt Ring object. - if ring.config.connections.slot == "on_scan_progress": - return - ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress()) + Args: + config(dict | None): Optional configuration dictionary for the ring. - def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int): + Returns: + Ring: The newly added ring object. """ - Hook the readback values to the progress bars. + return self.ring_progress_bar.add_ring(config=config) + def remove_ring(self, index: int | None = None): + """ + Remove a ring from the ring progress bar. Args: - bar_index(int): Index of the progress bar to hook the readback values to. - device(str): Device to readback values from. - min(float|int): Minimum value for the progress bar. - max(float|int): Maximum value for the progress bar. + index(int | None): Index of the ring to remove. If None, removes the last ring. """ - ring = self._find_ring_by_index(bar_index) - ring.set_min_max_values(min, max) - endpoint = MessageEndpoints.device_readback(device) - ring.set_connections("on_device_readback", endpoint) + if self.ring_progress_bar.num_bars == 0: + return + self.ring_progress_bar.remove_ring(index=index) - def _adjust_list_to_bars(self, items: list) -> list: + def set_gap(self, value: int): """ - Utility method to adjust the list of parameters to match the number of progress bars. + Set the gap between rings. Args: - items(list): List of parameters for the progress bars. - - Returns: - list: List of parameters for the progress bars. + value(int): Gap value in pixels. """ - if items is None: - raise ValueError( - "Items cannot be None. Please provide a list for parameters for the progress bars." - ) - if not isinstance(items, list): - items = [items] - if len(items) < self.config.num_bars: - last_item = items[-1] - items.extend([last_item] * (self.config.num_bars - len(items))) - elif len(items) > self.config.num_bars: - items = items[: self.config.num_bars] - return items + self.gap = value - def _bar_index_check(self, bar_index: int): + def set_center_label(self, text: str): """ - Utility method to check if the bar index is within the range of the number of progress bars. + Set the center label text. Args: - bar_index(int): Index of the progress bar to set the value for. + text(str): Text for the center label. """ - if not (0 <= bar_index < self.config.num_bars): - raise ValueError( - f"bar_index {bar_index} out of range of number of bars {self.config.num_bars}." - ) - return bar_index + self.center_label = text + + @property + def rings(self) -> list[Ring]: + return self.ring_progress_bar.rings + + ############################################### + ####### QProperties ########################### + ############################################### - def paintEvent(self, event): - if not self._rings: + @SafeProperty(int) + def gap(self) -> int: + return self.ring_progress_bar.gap + + @gap.setter + def gap(self, value: int): + self.ring_progress_bar.gap = value + self.ring_progress_bar.update() + + @SafeProperty(str) + def color_map(self) -> str: + return self.ring_progress_bar.color_map or "" + + @color_map.setter + def color_map(self, colormap: str): + if colormap == "": + self.ring_progress_bar.color_map = "" return - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - size = min(self.width(), self.height()) - rect = QtCore.QRect(0, 0, size, size) - rect.adjust( - max(ring.config.line_width for ring in self._rings), - max(ring.config.line_width for ring in self._rings), - -max(ring.config.line_width for ring in self._rings), - -max(ring.config.line_width for ring in self._rings), - ) + if colormap not in pg.colormap.listMaps(): + return + self.ring_progress_bar.set_colors_from_map(colormap) + self.ring_progress_bar.color_map = colormap - for i, ring in enumerate(self._rings): - # Background arc - painter.setPen( - QtGui.QPen(ring.background_color, ring.config.line_width, QtCore.Qt.SolidLine) - ) - offset = self.config.gap * i - adjusted_rect = QtCore.QRect( - rect.left() + offset, - rect.top() + offset, - rect.width() - 2 * offset, - rect.height() - 2 * offset, - ) - painter.drawArc(adjusted_rect, ring.config.start_position, 360 * 16) - - # Foreground arc - pen = QtGui.QPen(ring.color, ring.config.line_width, QtCore.Qt.SolidLine) - pen.setCapStyle(QtCore.Qt.RoundCap) - painter.setPen(pen) - proportion = (ring.config.value - ring.config.min_value) / ( - (ring.config.max_value - ring.config.min_value) + 1e-3 - ) - angle = int(proportion * 360 * 16 * ring.config.direction) - painter.drawArc(adjusted_rect, ring.start_position, angle) + @SafeProperty(str) + def center_label(self) -> str: + return self.ring_progress_bar.center_label.text() + + @center_label.setter + def center_label(self, text: str): + self.ring_progress_bar.center_label.setText(text) - def reset_diameter(self): + @SafeProperty(str, designable=False, popup_error=True) + def ring_json(self) -> str: """ - Reset the fixed size of the widget. + A JSON string property that serializes all ring pydantic configs. """ - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - self.setMinimumSize(self._calculate_minimum_size()) - self.setMaximumSize(16777215, 16777215) + raw_list = [] + for ring in self.rings: + cfg_dict = ring.config.model_dump() + raw_list.append(cfg_dict) + return json.dumps(raw_list, indent=2) - def _calculate_minimum_size(self): + @ring_json.setter + def ring_json(self, json_data: str): """ - Calculate the minimum size of the widget. + Load rings from a JSON string and add them to the ring progress bar. """ - if not self.config.rings: - logger.warning("no rings to get size from setting size to 10x10") - return QSize(10, 10) - ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)] - total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1) - diameter = max(total_width * 2, 50) + try: + ring_configs = json.loads(json_data) + self.ring_progress_bar.clear_all() + for cfg_dict in ring_configs: + self.add_ring(config=cfg_dict) + except json.JSONDecodeError as e: + logger.error(f"Failed to decode JSON: {e}") - return QSize(diameter, diameter) + def cleanup(self): + self.ring_progress_bar.clear_all() + self.ring_progress_bar.close() + self.ring_progress_bar.deleteLater() + super().cleanup() - def sizeHint(self): - min_size = self._calculate_minimum_size() - return min_size - def clear_all(self): - for ring in self._rings: - ring.reset_connection() - self._rings.clear() - self.update() - self.initialize_bars() +if __name__ == "__main__": # pragma: no cover + import sys - def cleanup(self): - self.bec_dispatcher.disconnect_slot( - self.on_scan_queue_status, MessageEndpoints.scan_queue_status() - ) - for ring in self._rings: - self._cleanup_ring(ring) - self._rings.clear() - super().cleanup() + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = RingProgressBar() + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py index f329166b4..bdc8a5594 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_plugin.py @@ -51,7 +51,7 @@ def name(self): return "RingProgressBar" def toolTip(self): - return "" + return "RingProgressBar" def whatsThis(self): return self.toolTip() diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py new file mode 100644 index 000000000..405646b09 --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py @@ -0,0 +1,509 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from bec_qthemes._icon.material_icons import material_icon +from qtpy.QtCore import QSize +from qtpy.QtGui import QColor +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils import UILoader +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.settings_dialog import SettingWidget +from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring +from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressBar, + RingProgressContainerWidget, + ) + + +class RingCardWidget(QFrame): + def __init__(self, ring: Ring, container: RingProgressContainerWidget, parent=None): + super().__init__(parent) + + self.ring = ring + self.container = container + self.details_visible = False + self.setProperty("skip_settings", True) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setObjectName("RingCardWidget") + + bg = self._get_theme_color("BORDER") + self.setStyleSheet( + f""" + #RingCardWidget {{ + border: 1px solid {bg.name() if bg else '#CCCCCC'}; + border-radius: 4px; + }} + """ + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + self._init_header(layout) + self._init_details(layout) + + self._init_values() + self._connect_signals() + self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode)) + self._set_widget_mode_enabled(self.ring.config.mode) + + def _get_theme_color(self, color_name: str) -> QColor | None: + app = QApplication.instance() + if not app: + return + if not app.theme: + return + return app.theme.color(color_name) + + def _init_header(self, parent_layout: QVBoxLayout): + """Create the collapsible header with basic controls""" + header = QWidget() + layout = QHBoxLayout(header) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + self.expand_btn = QPushButton("▶") + self.expand_btn.setFixedWidth(24) + self.expand_btn.clicked.connect(self.toggle_details) + + self.mode_combo = QComboBox() + self.mode_combo.addItems(["Manual", "Scan Progress", "Device Readback"]) + self.mode_combo.currentTextChanged.connect(self._update_mode) + + delete_btn = QPushButton(material_icon("delete"), "") + + color = self._get_theme_color("ACCENT_HIGHLIGHT") + delete_btn.setStyleSheet(f"background-color: {color.name() if color else '#CC181E'}") + delete_btn.clicked.connect(self._delete_self) + + layout.addWidget(self.expand_btn) + layout.addWidget(QLabel("Mode")) + layout.addWidget(self.mode_combo) + layout.addStretch() + layout.addWidget(delete_btn) + + parent_layout.addWidget(header) + + def _init_details(self, parent_layout: QVBoxLayout): + """Create the collapsible details area with the UI file""" + self.details = QWidget() + self.details.setVisible(False) + + details_layout = QVBoxLayout(self.details) + details_layout.setContentsMargins(0, 0, 0, 0) + + # Load UI file into details area + current_path = os.path.dirname(__file__) + self.ui = UILoader().load_ui(os.path.join(current_path, "ring_settings.ui"), self.details) + details_layout.addWidget(self.ui) + + parent_layout.addWidget(self.details) + + def toggle_details(self): + """Toggle visibility of the details area""" + self.details_visible = not self.details_visible + self.details.setVisible(self.details_visible) + self.expand_btn.setText("▼" if self.details_visible else "▶") + + # -------------------------------------------------------- + + def _connect_signals(self): + """Connect UI signals to ring methods""" + # Data connections + self.ui.value_spin_box.valueChanged.connect(self.ring.set_value) + self.ui.min_spin_box.valueChanged.connect(self._update_min_max) + self.ui.max_spin_box.valueChanged.connect(self._update_min_max) + + # Config connections + self.ui.start_angle_spin_box.valueChanged.connect(self.ring.set_start_angle) + self.ui.direction_combo_box.currentIndexChanged.connect(self._update_direction) + self.ui.line_width_spin_box.valueChanged.connect(self.ring.set_line_width) + self.ui.background_color_button.color_changed.connect(self.ring.set_background) + self.ui.ring_color_button.color_changed.connect(self._on_ring_color_changed) + self.ui.device_combo_box.device_selected.connect(self._on_device_changed) + self.ui.signal_combo_box.device_signal_changed.connect(self._on_signal_changed) + + def _init_values(self): + """Initialize UI values from ring config""" + # Data values + self.ui.value_spin_box.setRange(-1e6, 1e6) + self.ui.value_spin_box.setValue(self.ring.config.value) + + self.ui.min_spin_box.setRange(-1e6, 1e6) + self.ui.min_spin_box.setValue(self.ring.config.min_value) + + self.ui.max_spin_box.setRange(-1e6, 1e6) + self.ui.max_spin_box.setValue(self.ring.config.max_value) + self._update_min_max() + + self.ui.device_combo_box.setEditable(True) + self.ui.signal_combo_box.setEditable(True) + + device, signal = self.ring.config.device, self.ring.config.signal + if device: + self.ui.device_combo_box.set_device(device) + if signal: + for i in range(self.ui.signal_combo_box.count()): + data_item = self.ui.signal_combo_box.itemData(i) + if data_item and data_item.get("obj_name") == signal: + self.ui.signal_combo_box.setCurrentIndex(i) + break + + # Config values + self.ui.start_angle_spin_box.setValue(self.ring.config.start_position) + self.ui.direction_combo_box.setCurrentIndex(0 if self.ring.config.direction == -1 else 1) + self.ui.line_width_spin_box.setRange(1, 100) + self.ui.line_width_spin_box.setValue(self.ring.config.line_width) + + # Colors + self.ui.ring_color_button.set_color(self.ring.color) + self.ui.color_sync_button.setCheckable(True) + self.ui.color_sync_button.setChecked(self.ring.config.link_colors) + + # Set initial button state based on link_colors + if self.ring.config.link_colors: + self.ui.color_sync_button.setIcon(material_icon("link")) + self.ui.color_sync_button.setToolTip( + "Colors are linked - background derives from main color" + ) + self.ui.background_color_button.setEnabled(False) + self.ui.background_color_label.setEnabled(False) + # Trigger sync to ensure background color is derived from main color + self.ring.set_color(self.ring.config.color) + self.ui.background_color_button.set_color(self.ring.background_color) + else: + self.ui.color_sync_button.setIcon(material_icon("link_off")) + self.ui.color_sync_button.setToolTip( + "Colors are unlinked - set background independently" + ) + self.ui.background_color_button.setEnabled(True) + self.ui.background_color_label.setEnabled(True) + self.ui.background_color_button.set_color(self.ring.background_color) + + self.ui.color_sync_button.toggled.connect(self._toggle_color_link) + + # -------------------------------------------------------- + + def _toggle_color_link(self, checked: bool): + """Toggle the color linking between main and background color""" + self.ring.config.link_colors = checked + + # Update button icon and tooltip based on state + if checked: + self.ui.color_sync_button.setIcon(material_icon("link")) + self.ui.color_sync_button.setToolTip( + "Colors are linked - background derives from main color" + ) + # Trigger background color update by calling set_color + self.ring.set_color(self.ring.config.color) + # Update UI to show the new background color + self.ui.background_color_button.set_color(self.ring.background_color) + else: + self.ui.color_sync_button.setIcon(material_icon("link_off")) + self.ui.color_sync_button.setToolTip( + "Colors are unlinked - set background independently" + ) + + # Enable/disable background color controls based on link state + self.ui.background_color_button.setEnabled(not checked) + self.ui.background_color_label.setEnabled(not checked) + + def _on_ring_color_changed(self, color: QColor): + """Handle ring color changes and update background if colors are linked""" + self.ring.set_color(color) + # If colors are linked, update the background color button to show the new derived color + if self.ring.config.link_colors: + self.ui.background_color_button.set_color(self.ring.background_color) + + def _update_min_max(self): + self.ui.value_spin_box.setRange(self.ui.min_spin_box.value(), self.ui.max_spin_box.value()) + self.ring.set_min_max_values(self.ui.min_spin_box.value(), self.ui.max_spin_box.value()) + + def _update_direction(self, index: int): + self.ring.config.direction = -1 if index == 0 else 1 + self.ring.update() + + @SafeSlot(str) + def _on_device_changed(self, device: str): + signal = self.ui.signal_combo_box.get_signal_name() + self.ring.set_update("device", device=device, signal=signal) + self.ring.config.device = device + + @SafeSlot(str) + def _on_signal_changed(self, signal: str): + device = self.ui.device_combo_box.currentText() + signal = self.ui.signal_combo_box.get_signal_name() + if not device or device not in self.container.bec_dispatcher.client.device_manager.devices: + return + self.ring.set_update("device", device=device, signal=signal) + self.ring.config.signal = signal + + def _unify_mode_string(self, mode: str) -> str: + """Convert mode string to a unified format""" + mode = mode.lower() + if mode == "scan progress": + return "scan" + if mode == "device readback": + return "device" + return mode + + def _get_display_mode_string(self, mode: str) -> str: + """Convert mode string to display format""" + match mode: + case "manual": + return "Manual" + case "scan": + return "Scan Progress" + case "device": + return "Device Readback" + return mode.capitalize() + + def _update_mode(self, mode: str): + """Update the ring's mode based on combo box selection""" + mode = self._unify_mode_string(mode) + match mode: + case "manual": + self.ring.set_update("manual") + case "scan": + self.ring.set_update("scan") + case "device": + self.ring.set_update("device", device=self.ui.device_combo_box.currentText()) + self._set_widget_mode_enabled(mode) + + def _set_widget_mode_enabled(self, mode: str): + """Show/hide controls based on the current mode""" + mode = self._unify_mode_string(mode) + self.ui.device_combo_box.setEnabled(mode == "device") + self.ui.signal_combo_box.setEnabled(mode == "device") + self.ui.device_label.setEnabled(mode == "device") + self.ui.signal_label.setEnabled(mode == "device") + self.ui.min_label.setEnabled(mode in ["manual", "device"]) + self.ui.max_label.setEnabled(mode in ["manual", "device"]) + self.ui.value_label.setEnabled(mode == "manual") + self.ui.value_spin_box.setEnabled(mode == "manual") + self.ui.min_spin_box.setEnabled(mode in ["manual", "device"]) + self.ui.max_spin_box.setEnabled(mode in ["manual", "device"]) + + def _delete_self(self): + """Delete this ring from the container""" + if self.ring in self.container.rings: + self.container.rings.remove(self.ring) + self.ring.deleteLater() + + self.cleanup() + + def cleanup(self): + """Cleanup the card widget""" + self.ui.device_combo_box.close() + self.ui.device_combo_box.deleteLater() + self.ui.signal_combo_box.close() + self.ui.signal_combo_box.deleteLater() + self.close() + self.deleteLater() + + +# ============================================================ +# Ring settings widget +# ============================================================ + + +class RingSettings(SettingWidget): + def __init__( + self, parent=None, target_widget: RingProgressBar | None = None, popup=False, **kwargs + ): + super().__init__(parent=parent, **kwargs) + + self.setProperty("skip_settings", True) + self.target_widget = target_widget + self.popup = popup + if not target_widget: + return + self.container: RingProgressContainerWidget = target_widget.ring_progress_bar + self.original_num_bars = len(self.container.rings) + self.original_configs = [ring.config.model_dump() for ring in self.container.rings] + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + add_button = QPushButton(material_icon("add"), "Add Ring") + add_button.clicked.connect(self.add_ring) + + self.center_label_edit = QLineEdit(self.container.center_label.text()) + self.center_label_edit.setPlaceholderText("Center Label") + self.center_label_edit.textChanged.connect(self._update_center_label) + + self.colormap_toggle = QPushButton() + self.colormap_toggle.setCheckable(True) + self.colormap_toggle.setIcon(material_icon("palette")) + self.colormap_toggle.setToolTip( + f"Colormap mode is {'enabled' if self.container.color_map else 'disabled'}" + ) + self.colormap_toggle.toggled.connect(self._toggle_colormap_mode) + + self.colormap_button = BECColorMapWidget(parent=self) + self.colormap_button.setToolTip("Set a global colormap for all rings") + self.colormap_button.colormap_changed_signal.connect(self._set_global_colormap) + + toolbar = QHBoxLayout() + + toolbar.addWidget(add_button) + toolbar.addWidget(self.center_label_edit) + + toolbar.addStretch() + toolbar.addWidget(self.colormap_toggle) + toolbar.addWidget(self.colormap_button) + + layout.addLayout(toolbar) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + + self.cards_container = QWidget() + self.cards_layout = QVBoxLayout(self.cards_container) + self.cards_layout.setSpacing(10) + self.cards_layout.addStretch() + + self.scroll.setWidget(self.cards_container) + layout.addWidget(self.scroll) + + self.refresh_from_container() + self.original_label = self.container.center_label.text() + + def sizeHint(self) -> QSize: + return QSize(720, 520) + + def refresh_from_container(self): + if not self.container: + return + + for ring in self.container.rings: + card = RingCardWidget(ring, self.container) + self.cards_layout.insertWidget(self.cards_layout.count() - 1, card) + + if self.container.color_map: + self.colormap_button.colormap = self.container.color_map + self.colormap_toggle.setChecked(bool(self.container.color_map)) + + @SafeSlot() + def add_ring(self): + if not self.container: + return + self.container.add_ring() + ring = self.container.rings[len(self.container.rings) - 1] + if ring: + card = RingCardWidget(ring, self.container) + self.cards_layout.insertWidget(self.cards_layout.count() - 1, card) + + # If a global colormap is set, apply it + if self.container.color_map: + self._toggle_colormap_mode(bool(self.container.color_map)) + + @SafeSlot(str) + def _update_center_label(self, text: str): + if not self.container: + return + self.container.center_label.setText(text) + + @SafeSlot(bool) + def _toggle_colormap_mode(self, enabled: bool): + self.colormap_toggle.setToolTip(f"Colormap mode is {'enabled' if enabled else 'disabled'}") + if enabled: + colormap = self.colormap_button.colormap + self._set_global_colormap(colormap) + else: + self.container.color_map = "" + for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch + widget = self.cards_layout.itemAt(i).widget() + if not isinstance(widget, RingCardWidget): + continue + widget.ui.ring_color_button.setEnabled(not enabled) + widget.ui.ring_color_button.setToolTip( + "Disabled in colormap mode" if enabled else "Set the ring color" + ) + widget.ui.ring_color_label.setEnabled(not enabled) + widget.ui.background_color_button.setEnabled( + not enabled and not widget.ring.config.link_colors + ) + widget.ui.color_sync_button.setEnabled(not enabled) + + @SafeSlot(str) + def _set_global_colormap(self, colormap: str): + if not self.container: + return + self.container.set_colors_from_map(colormap) + + # Update all ring card color buttons to reflect the new colors + for i in range(self.cards_layout.count() - 1): # -1 to exclude the stretch + widget = self.cards_layout.itemAt(i).widget() + if not isinstance(widget, RingCardWidget): + continue + widget.ui.ring_color_button.set_color(widget.ring.color) + if widget.ring.config.link_colors: + widget.ui.background_color_button.set_color(widget.ring.background_color) + + @SafeSlot() + def accept_changes(self): + if not self.container: + return + + self.original_configs = [ring.config.model_dump() for ring in self.container.rings] + + for i, ring in enumerate(self.container.rings): + ring.setGeometry(self.container.rect()) + ring.gap = self.container.gap * i + ring.show() # Ensure ring is visible + ring.raise_() # Bring ring to front + + self.container.center_label.setText(self.center_label_edit.text()) + self.original_label = self.container.center_label.text() + self.original_num_bars = len(self.container.rings) + + self.container.update() + + def cleanup(self): + """ + Cleanup the settings widget. + """ + # Remove any rings that were added but not applied + if not self.container: + return + if len(self.container.rings) > self.original_num_bars: + remove_rings = self.container.rings[self.original_num_bars :] + for ring in remove_rings: + self.container.rings.remove(ring) + ring.deleteLater() + rings_to_add = max(0, self.original_num_bars - len(self.container.rings)) + for _ in range(rings_to_add): + self.container.add_ring() + + # apply original configs to all rings + for i, ring in enumerate(self.container.rings): + ring.config = ring.config.model_validate(self.original_configs[i]) + + for i in range(self.cards_layout.count()): + item = self.cards_layout.itemAt(i) + if not item or not item.widget(): + continue + widget: RingCardWidget = item.widget() + widget.cleanup() + self.container.update() + self.container.center_label.setText(self.original_label) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui b/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui new file mode 100644 index 000000000..fd3c4963c --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_settings.ui @@ -0,0 +1,235 @@ + + + Form + + + + 0 + 0 + 731 + 199 + + + + Form + + + + + + Data + + + + + + + + + + + + Value + + + + + + + Min + + + + + + + Max + + + + + + + + + + Signal + + + + + + + + + + + + + Device + + + + + + + Qt::Orientation::Horizontal + + + + + + + + + + Config + + + + + + Qt::Orientation::Horizontal + + + + + + + ° + + + 360 + + + 90 + + + + + + + Line Width + + + + + + + + Clockwise + + + + + Counter-clockwise + + + + + + + + Direction + + + + + + + 12 + + + + + + + Start Angle + + + + + + + + + + + + + Background Color + + + + + + + Ring Color + + + + + + + ... + + + + + + + + + + + ColorButtonNative + +
color_button_native
+
+ + DeviceComboBox + +
device_combo_box
+
+ + SignalComboBox + +
signal_combo_box
+
+
+ + + + device_combo_box + currentTextChanged(QString) + signal_combo_box + set_device(QString) + + + 209 + 133 + + + 213 + 153 + + + + + device_combo_box + device_reset() + signal_combo_box + reset_selection() + + + 248 + 135 + + + 250 + 147 + + + + +
diff --git a/docs/user/widgets/progress_bar/ring_progress_bar.md b/docs/user/widgets/progress_bar/ring_progress_bar.md index b340ba0c0..1617e66a1 100644 --- a/docs/user/widgets/progress_bar/ring_progress_bar.md +++ b/docs/user/widgets/progress_bar/ring_progress_bar.md @@ -24,11 +24,13 @@ In this example, we demonstrate how to add a `RingProgressBar` widget to a `BECD ```python # Add a new dock with a RingProgressBar widget -dock_area = gui.new('my_new_dock_area') # Create a new dock area -progress = dock_area.new().new(gui.available_widgets.RingProgressBar) +dock_area = gui.new() # Create a new dock area +progress = dock_area.new(gui.available_widgets.RingProgressBar) -# Customize the size of the progress ring -progress.set_line_widths(20) +# Add a ring to the RingProgressBar +progress.add_ring() +ring = progress.rings[0] +ring.set_value(50) # Set the progress value to 50 ``` ## Example 2 - Adding Multiple Rings to Track Parallel Tasks @@ -40,8 +42,7 @@ By default, the `RingProgressBar` widget displays a single ring. You can add add progress.add_ring() # Customize the rings -progress.rings[0].set_line_widths(20) # Set the width of the first ring -progress.rings[1].set_line_widths(10) # Set the width of the second ring +progress.rings[1].set_value(30) # Set the second ring to 30 ``` ## Example 3 - Integrating with Device Readback and Scans @@ -56,44 +57,6 @@ progress.rings[0].set_update("scan") progress.rings[1].set_update("device", "samx") ``` -## Example 4 - Customizing Visual Elements of the Rings - -The `RingProgressBar` widget offers various customization options, such as changing colors, line widths, and the gap between rings. - -```python -# Set the color of the first ring to blue -progress.rings[0].set_color("blue") - -# Set the background color of the second ring -progress.rings[1].set_background("gray") - -# Adjust the gap between the rings -progress.set_gap(5) - -# Set the diameter of the progress bar -progress.set_diameter(150) -``` - -## Example 5 - Manual Updates and Precision Control - -While the `RingProgressBar` supports automatic updates, you can also manually control the progress and set the precision for each ring. - -```python -# Disable automatic updates and manually set the progress value -progress.enable_auto_updates(False) -progress.rings[0].set_value(75) # Set the first ring to 75% - -# Set precision for the progress display -progress.set_precision(2) # Display progress with two decimal places - - -# Setting multiple rigns with different values -progress.set_number_of_bars(3) - -# Set the values of the rings to 50, 75, and 25 from outer to inner ring -progress.set_value([50, 75, 25]) -``` - ```` ````{tab} API diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 076b32d95..aba9a6d17 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -537,30 +537,32 @@ def test_widgets_e2e_positioner_control_line( # TODO passes locally, fails on CI for some reason... -> issue #1003 -# @pytest.mark.timeout(PYTEST_TIMEOUT) -# def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): -# """Test the RingProgressBar widget""" -# gui = connected_client_gui_obj -# bec = gui._client -# # Create dock_area and widget -# widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) -# widget: client.RingProgressBar -# -# widget.set_number_of_bars(3) -# widget.rings[0].set_update("manual") -# widget.rings[0].set_value(30) -# widget.rings[0].set_min_max_values(0, 100) -# widget.rings[1].set_update("scan") -# widget.rings[2].set_update("device", device="samx") -# -# # Test rpc calls -# dev = bec.device_manager.devices -# scans = bec.scans -# # Do a scan -# scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait() -# -# # Test removing the widget, or leaving it open for the next test -# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) +@pytest.mark.timeout(PYTEST_TIMEOUT) +def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): + """Test the RingProgressBar widget""" + gui = connected_client_gui_obj + bec = gui._client + # Create dock_area and widget + widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar) + widget: client.RingProgressBar + + widget.add_ring() + widget.add_ring() + widget.add_ring() + widget.rings[0].set_update("manual") + widget.rings[0].set_value(30) + widget.rings[0].set_min_max_values(0, 100) + widget.rings[1].set_update("scan") + widget.rings[2].set_update("device", device="samx") + + # Test rpc calls + dev = bec.device_manager.devices + scans = bec.scans + # Do a scan + scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait() + + # Test removing the widget, or leaving it open for the next test + maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) @pytest.mark.timeout(PYTEST_TIMEOUT) diff --git a/tests/unit_tests/test_ring_progress_bar.py b/tests/unit_tests/test_ring_progress_bar.py index 9ff95baf6..e858e80da 100644 --- a/tests/unit_tests/test_ring_progress_bar.py +++ b/tests/unit_tests/test_ring_progress_bar.py @@ -1,13 +1,14 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +import json + import pytest from bec_lib.endpoints import MessageEndpoints from pydantic import ValidationError +from qtpy.QtGui import QColor from bec_widgets.utils import Colors -from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar -from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConnections, RingConfig -from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBarConfig +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar from .client_mocks import mocked_client @@ -29,176 +30,117 @@ def test_bar_init(ring_progress_bar): assert ring_progress_bar.gui_id == ring_progress_bar.config.gui_id -def test_config_validation_num_of_bars(): - config = RingProgressBarConfig(num_bars=100, min_num_bars=1, max_num_bars=10) - - assert config.num_bars == 10 - - -def test_config_validation_num_of_ring_error(): - ring_config_0 = RingConfig(index=0) - ring_config_1 = RingConfig(index=1) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=1) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "different number of configs" - assert "Length of rings configuration (2) does not match the number of bars (1)." in str( - excinfo.value - ) - - -def test_config_validation_ring_indices_wrong_order(): - ring_config_0 = RingConfig(index=2) - ring_config_1 = RingConfig(index=5) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "wrong indices" - assert ( - "Indices of ring configurations must be unique and in order from 0 to num_bars 2." - in str(excinfo.value) - ) - - -def test_config_validation_ring_same_indices(): - ring_config_0 = RingConfig(index=0) - ring_config_1 = RingConfig(index=0) - - with pytest.raises(ValidationError) as excinfo: - RingProgressBarConfig(rings=[ring_config_0, ring_config_1], num_bars=2) - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "wrong indices" - assert ( - "Indices of ring configurations must be unique and in order from 0 to num_bars 2." - in str(excinfo.value) - ) - - -def test_config_validation_invalid_colormap(): - with pytest.raises(ValueError) as excinfo: - RingProgressBarConfig(color_map="crazy_colors") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported colormap" - assert "Colormap 'crazy_colors' not found in the current installation of pyqtgraph" in str( - excinfo.value - ) - - -def test_ring_connection_endpoint_validation(): - with pytest.raises(ValueError) as excinfo: - ProgressbarConnections(slot="on_scan_progress", endpoint="non_existing") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported endpoint" - assert ( - "For slot 'on_scan_progress', endpoint must be MessageEndpoint.scan_progress or 'scans/scan_progress'." - in str(excinfo.value) - ) - - with pytest.raises(ValueError) as excinfo: - ProgressbarConnections(slot="on_device_readback", endpoint="non_existing") - errors = excinfo.value.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "unsupported endpoint" - assert ( - "For slot 'on_device_readback', endpoint must be MessageEndpoint.device_readback(device) or 'internal/devices/readback/{device}'." - in str(excinfo.value) - ) - - -def test_bar_add_number_of_bars(ring_progress_bar): - assert ring_progress_bar.config.num_bars == 1 - - ring_progress_bar.set_number_of_bars(5) - assert ring_progress_bar.config.num_bars == 5 - - ring_progress_bar.set_number_of_bars(2) - assert ring_progress_bar.config.num_bars == 2 - - -def test_add_remove_bars_individually(ring_progress_bar): - ring_progress_bar.add_ring() +def test_rpb_center_label(ring_progress_bar): + test_text = "Center Label" + ring_progress_bar.set_center_label(test_text) + assert ring_progress_bar.center_label == test_text + assert ring_progress_bar.ring_progress_bar.center_label.text() == test_text + + +def test_add_ring(qtbot, ring_progress_bar): + ring_progress_bar.show() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == len(ring_progress_bar.rings) ring_progress_bar.add_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars + 1 + assert len(ring_progress_bar.rings) == initial_num_bars + 1 + qtbot.wait(200) - assert ring_progress_bar.config.num_bars == 3 - assert len(ring_progress_bar.config.rings) == 3 - ring_progress_bar.remove_ring(1) - assert ring_progress_bar.config.num_bars == 2 - assert len(ring_progress_bar.config.rings) == 2 - assert ring_progress_bar.rings[0].config.index == 0 - assert ring_progress_bar.rings[1].config.index == 1 +def test_remove_ring(ring_progress_bar): + ring_progress_bar.add_ring() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == len(ring_progress_bar.rings) + ring_progress_bar.remove_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars - 1 + assert len(ring_progress_bar.rings) == initial_num_bars - 1 -def test_bar_set_value(ring_progress_bar): - ring_progress_bar.set_number_of_bars(5) +def test_remove_ring_no_bars(ring_progress_bar): + # Remove all rings first + while ring_progress_bar.ring_progress_bar.num_bars > 0: + ring_progress_bar.remove_ring() + initial_num_bars = ring_progress_bar.ring_progress_bar.num_bars + assert initial_num_bars == 0 + # Attempt to remove a ring when there are none + ring_progress_bar.remove_ring() + assert ring_progress_bar.ring_progress_bar.num_bars == initial_num_bars + assert len(ring_progress_bar.rings) == initial_num_bars - assert ring_progress_bar.config.num_bars == 5 - assert len(ring_progress_bar.config.rings) == 5 - assert len(ring_progress_bar.rings) == 5 - ring_progress_bar.set_value([10, 20, 30, 40, 50]) - ring_values = [ring.config.value for ring in ring_progress_bar.rings] - assert ring_values == [10, 20, 30, 40, 50] +def test_bar_set_value(ring_progress_bar): + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - # update just one bar - ring_progress_bar.set_value(90, 1) + ring_progress_bar.rings[0].set_value(10) + ring_progress_bar.rings[1].set_value(20) ring_values = [ring.config.value for ring in ring_progress_bar.rings] - assert ring_values == [10, 90, 30, 40, 50] + assert ring_values == [10, 20] def test_bar_set_precision(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - assert ring_progress_bar.config.num_bars == 3 - assert len(ring_progress_bar.config.rings) == 3 + assert ring_progress_bar.ring_progress_bar.num_bars == 3 assert len(ring_progress_bar.rings) == 3 - ring_progress_bar.set_precision(2) + # Set precision for all rings + for ring in ring_progress_bar.rings: + ring.set_precision(2) ring_precision = [ring.config.precision for ring in ring_progress_bar.rings] assert ring_precision == [2, 2, 2] - ring_progress_bar.set_value([10.1234, 20.1234, 30.1234]) + # Set values + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_value([10.1234, 20.1234, 30.1234][i]) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [10.12, 20.12, 30.12] - ring_progress_bar.set_precision(4, 1) + # Set precision for ring at index 1 + ring_progress_bar.rings[1].set_precision(4) ring_precision = [ring.config.precision for ring in ring_progress_bar.rings] assert ring_precision == [2, 4, 2] - ring_progress_bar.set_value([10.1234, 20.1234, 30.1234]) + # Set values again + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_value([10.1234, 20.1234, 30.1234][i]) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [10.12, 20.1234, 30.12] def test_set_min_max_value(ring_progress_bar): - ring_progress_bar.set_number_of_bars(2) + # Add 2 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() - ring_progress_bar.set_min_max_values(0, 10) + # Set min/max values for all rings + for ring in ring_progress_bar.rings: + ring.set_min_max_values(0, 10) ring_min_values = [ring.config.min_value for ring in ring_progress_bar.rings] ring_max_values = [ring.config.max_value for ring in ring_progress_bar.rings] assert ring_min_values == [0, 0] assert ring_max_values == [10, 10] - ring_progress_bar.set_value([5, 15]) + # Set values + ring_progress_bar.rings[0].set_value(5) + ring_progress_bar.rings[1].set_value(15) ring_values = [ring.config.value for ring in ring_progress_bar.rings] assert ring_values == [5, 10] def test_setup_colors_from_colormap(ring_progress_bar): - ring_progress_bar.set_number_of_bars(5) - ring_progress_bar.set_colors_from_map("viridis", "RGB") + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 5, "RGB") converted_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] - ring_config_colors = [ring.config.color for ring in ring_progress_bar.rings] + ring_config_colors = [QColor(ring.config.color).getRgb() for ring in ring_progress_bar.rings] assert expected_colors == converted_colors assert ring_config_colors == expected_colors @@ -206,13 +148,15 @@ def test_setup_colors_from_colormap(ring_progress_bar): def get_colors_from_rings(rings): converted_colors = [ring.color.getRgb() for ring in rings] - ring_config_colors = [ring.config.color for ring in rings] + ring_config_colors = [QColor(ring.config.color).getRgb() for ring in rings] return converted_colors, ring_config_colors def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): - ring_progress_bar.set_number_of_bars(2) - ring_progress_bar.set_colors_from_map("viridis", "RGB") + # Add 2 rings + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 2, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -221,7 +165,9 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): assert ring_config_colors == expected_colors # increase the number of bars to 6 - ring_progress_bar.set_number_of_bars(6) + for _ in range(4): + ring_progress_bar.add_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 6, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -229,7 +175,9 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): assert ring_config_colors == expected_colors # decrease the number of bars to 3 - ring_progress_bar.set_number_of_bars(3) + for _ in range(3): + ring_progress_bar.remove_ring() + ring_progress_bar.ring_progress_bar.set_colors_from_map("viridis", "RGB") expected_colors = Colors.golden_angle_color("viridis", 3, "RGB") converted_colors, ring_config_colors = get_colors_from_rings(ring_progress_bar.rings) @@ -238,100 +186,284 @@ def test_set_colors_from_colormap_and_change_num_of_bars(ring_progress_bar): def test_set_colors_directly(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() # setting as a list of rgb tuples colors = [(255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255)] - ring_progress_bar.set_colors_directly(colors) + ring_progress_bar.ring_progress_bar.set_colors_directly(colors) converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0] assert colors == converted_colors - ring_progress_bar.set_colors_directly((255, 0, 0, 255), 1) + ring_progress_bar.ring_progress_bar.set_colors_directly((255, 0, 0, 255), 1) converted_colors = get_colors_from_rings(ring_progress_bar.rings)[0] assert converted_colors == [(255, 0, 0, 255), (255, 0, 0, 255), (0, 0, 255, 255)] def test_set_line_width(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() - ring_progress_bar.set_line_widths(5) + # Set line width for all rings + for ring in ring_progress_bar.rings: + ring.set_line_width(5) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [5, 5, 5] - ring_progress_bar.set_line_widths([10, 20, 30]) + # Set individual line widths + for i, ring in enumerate(ring_progress_bar.rings): + ring.set_line_width([10, 20, 30][i]) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [10, 20, 30] - ring_progress_bar.set_line_widths(15, 1) + # Set line width for ring at index 1 + ring_progress_bar.rings[1].set_line_width(15) line_widths = [ring.config.line_width for ring in ring_progress_bar.rings] assert line_widths == [10, 15, 30] def test_set_gap(ring_progress_bar): - ring_progress_bar.set_number_of_bars(3) + ring_progress_bar.add_ring() ring_progress_bar.set_gap(20) - assert ring_progress_bar.config.gap == 20 + assert ring_progress_bar.ring_progress_bar.gap == 20 == ring_progress_bar.gap -def test_auto_update(ring_progress_bar): - ring_progress_bar.enable_auto_updates(True) +def test_remove_ring_by_index(ring_progress_bar): + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() - scan_queue_status_scan_progress = { - "queue": { - "primary": { - "info": [{"active_request_block": {"report_instructions": [{"scan_progress": 10}]}}] - } - } - } - meta = {} - - ring_progress_bar.on_scan_queue_status(scan_queue_status_scan_progress, meta) - - assert ring_progress_bar._auto_updates is True - assert len(ring_progress_bar._rings) == 1 - assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections( - slot="on_scan_progress", endpoint=MessageEndpoints.scan_progress() - ) - - scan_queue_status_device_readback = { - "queue": { - "primary": { - "info": [ - { - "active_request_block": { - "report_instructions": [ - { - "readback": { - "devices": ["samx", "samy"], - "start": [1, 2], - "end": [10, 20], - } - } - ] - } - } - ] - } - } + assert ring_progress_bar.ring_progress_bar.num_bars == 5 + + # Store the ring at index 2 before removal + ring_at_3 = ring_progress_bar.rings[3] + + # Remove ring at index 2 (middle ring) + ring_progress_bar.remove_ring(index=2) + + assert ring_progress_bar.ring_progress_bar.num_bars == 4 + # Ring that was at index 3 is now at index 2 + assert ring_progress_bar.rings[2] == ring_at_3 + + +def test_remove_ring_updates_gaps(ring_progress_bar): + # Add 3 rings with default gap + for _ in range(3): + ring_progress_bar.add_ring() + + initial_gap = ring_progress_bar.gap + # Gaps should be: 0, gap, 2*gap + expected_gaps = [0, initial_gap, 2 * initial_gap] + actual_gaps = [ring.gap for ring in ring_progress_bar.rings] + assert actual_gaps == expected_gaps + + # Remove middle ring + ring_progress_bar.remove_ring(index=1) + + # Gaps should now be: 0, gap (for the remaining 2 rings) + expected_gaps = [0, initial_gap] + actual_gaps = [ring.gap for ring in ring_progress_bar.rings] + assert actual_gaps == expected_gaps + + +def test_center_label_property(ring_progress_bar): + test_text = "Test Label" + ring_progress_bar.center_label = test_text + + assert ring_progress_bar.center_label == test_text + assert ring_progress_bar.ring_progress_bar.center_label.text() == test_text + + +def test_color_map_property(ring_progress_bar): + # Add some rings + for _ in range(3): + ring_progress_bar.add_ring() + + # Set colormap via property + ring_progress_bar.color_map = "plasma" + + assert ring_progress_bar.color_map == "plasma" + assert ring_progress_bar.ring_progress_bar.color_map == "plasma" + + # Verify colors were applied + expected_colors = Colors.golden_angle_color("plasma", 3, "RGB") + actual_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert actual_colors == expected_colors + + +def test_color_map_property_invalid_colormap(ring_progress_bar): + # Make sure that invalid colormaps do not crash the application + ring_progress_bar.color_map = "plasma" + ring_progress_bar.color_map = "invalid_colormap_name" + + assert ring_progress_bar.color_map == "plasma" # Should remain unchanged + + +def test_ring_json_serialization(ring_progress_bar): + # Add rings with specific configurations + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + ring_progress_bar.add_ring() + + # Configure rings + ring_progress_bar.rings[0].set_value(25) + ring_progress_bar.rings[0].set_color((255, 0, 0, 255)) + ring_progress_bar.rings[1].set_value(50) + ring_progress_bar.rings[1].set_line_width(15) + ring_progress_bar.rings[2].set_value(75) + ring_progress_bar.rings[2].set_precision(4) + + # Get JSON + json_str = ring_progress_bar.ring_json + + # Verify it's valid JSON + ring_configs = json.loads(json_str) + assert isinstance(ring_configs, list) + assert len(ring_configs) == 3 + + # Check some values + assert ring_configs[0]["value"] == 25 + assert ring_configs[1]["value"] == 50 + assert ring_configs[1]["line_width"] == 15 + assert ring_configs[2]["precision"] == 4 + + +def test_ring_json_deserialization(ring_progress_bar): + # Create JSON config + ring_configs = [ + {"value": 10, "color": (100, 150, 200, 255), "line_width": 8}, + {"value": 20, "precision": 2, "min_value": 0, "max_value": 50}, + {"value": 30, "direction": 1}, + ] + json_str = json.dumps(ring_configs) + + # Load via property + ring_progress_bar.ring_json = json_str + + # Verify rings were created + assert len(ring_progress_bar.rings) == 3 + + # Verify configurations + assert ring_progress_bar.rings[0].config.value == 10 + assert ring_progress_bar.rings[0].config.line_width == 8 + assert ring_progress_bar.rings[1].config.precision == 2 + assert ring_progress_bar.rings[1].config.max_value == 50 + assert ring_progress_bar.rings[2].config.direction == 1 + + +def test_ring_json_replaces_existing_rings(ring_progress_bar): + # Add some initial rings + for _ in range(5): + ring_progress_bar.add_ring() + + assert len(ring_progress_bar.rings) == 5 + + # Load new config with only 2 rings + ring_configs = [{"value": 10}, {"value": 20}] + ring_progress_bar.ring_json = json.dumps(ring_configs) + + # Should have replaced all rings + assert len(ring_progress_bar.rings) == 2 + assert ring_progress_bar.rings[0].config.value == 10 + assert ring_progress_bar.rings[1].config.value == 20 + + +def test_add_ring_with_config(ring_progress_bar): + config = { + "value": 42, + "color": (128, 64, 192, 255), + "line_width": 12, + "precision": 1, + "min_value": 0, + "max_value": 100, } - ring_progress_bar.on_scan_queue_status(scan_queue_status_device_readback, meta) - - assert ring_progress_bar._auto_updates is True - assert len(ring_progress_bar._rings) == 2 - assert ring_progress_bar._rings[0].config.connections == ProgressbarConnections( - slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samx") - ) - assert ring_progress_bar._rings[1].config.connections == ProgressbarConnections( - slot="on_device_readback", endpoint=MessageEndpoints.device_readback("samy") - ) - - assert ring_progress_bar._rings[0].config.min_value == 1 - assert ring_progress_bar._rings[0].config.max_value == 10 - assert ring_progress_bar._rings[1].config.min_value == 2 - assert ring_progress_bar._rings[1].config.max_value == 20 + ring_progress_bar.color_map = "" + ring_progress_bar.add_ring(config=config) + + assert len(ring_progress_bar.rings) == 1 + ring = ring_progress_bar.rings[0] + + assert ring.config.value == 42 + assert ring.config.line_width == 12 + assert ring.config.precision == 1 + assert ring.config.max_value == 100 + assert ring.config.color == "#8040c0" # Hex representation of (128, 64, 192) + + +def test_set_colors_directly_single_color_extends_to_all(ring_progress_bar): + # Add 4 rings + for _ in range(4): + ring_progress_bar.add_ring() + + # Set a single color, should extend to all rings + single_color = (200, 100, 50, 255) + ring_progress_bar.ring_progress_bar.set_colors_directly(single_color) + + colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert all(color == single_color for color in colors) + + +def test_set_colors_directly_list_too_short(ring_progress_bar): + # Add 5 rings + for _ in range(5): + ring_progress_bar.add_ring() + + # Provide only 2 colors + colors = [(255, 0, 0, 255), (0, 255, 0, 255)] + ring_progress_bar.ring_progress_bar.set_colors_directly(colors) + + # Last color should be extended to remaining rings + actual_colors = [ring.color.getRgb() for ring in ring_progress_bar.rings] + assert actual_colors[0] == (255, 0, 0, 255) + assert actual_colors[1] == (0, 255, 0, 255) + assert all(color == (0, 255, 0, 255) for color in actual_colors[2:]) + + +def test_gap_affects_ring_positioning(ring_progress_bar): + # Add 3 rings + for _ in range(3): + ring_progress_bar.add_ring() + + initial_gap = ring_progress_bar.gap + + # Change gap + new_gap = 30 + ring_progress_bar.set_gap(new_gap) + + # Verify gaps are updated but update method is needed for visual changes + assert ring_progress_bar.gap == new_gap + + +def test_clear_all_rings(ring_progress_bar): + # Add multiple rings + for _ in range(5): + ring_progress_bar.add_ring() + + assert len(ring_progress_bar.rings) == 5 + + # Clear all + ring_progress_bar.ring_progress_bar.clear_all() + + assert len(ring_progress_bar.rings) == 0 + assert ring_progress_bar.ring_progress_bar.num_bars == 0 + + +def test_rings_property_returns_correct_list(ring_progress_bar): + # Add some rings + for _ in range(3): + ring_progress_bar.add_ring() + + rings_via_property = ring_progress_bar.rings + rings_direct = ring_progress_bar.ring_progress_bar.rings + + # Should return the same list + assert rings_via_property is rings_direct + assert len(rings_via_property) == 3 diff --git a/tests/unit_tests/test_ring_progress_bar_ring.py b/tests/unit_tests/test_ring_progress_bar_ring.py new file mode 100644 index 000000000..d3437b8d1 --- /dev/null +++ b/tests/unit_tests/test_ring_progress_bar_ring.py @@ -0,0 +1,602 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring + +from unittest.mock import MagicMock + +import pytest +from qtpy.QtGui import QColor + +from bec_widgets.tests.utils import FakeDevice +from bec_widgets.widgets.progress.ring_progress_bar.ring import ProgressbarConfig, Ring +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressContainerWidget, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def ring_container(qtbot, mocked_client): + container = RingProgressContainerWidget() + qtbot.addWidget(container) + yield container + + +@pytest.fixture +def ring_widget(qtbot, ring_container, mocked_client): + ring = Ring(parent=ring_container, client=mocked_client) + qtbot.addWidget(ring) + qtbot.waitExposed(ring) + yield ring + + +@pytest.fixture +def ring_widget_with_device(ring_widget): + mock_device = FakeDevice(name="samx") + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + yield ring_widget + + +def test_ring_initialization(ring_widget): + assert ring_widget is not None + assert isinstance(ring_widget.config, ProgressbarConfig) + assert ring_widget.config.mode == "manual" + assert ring_widget.config.value == 0 + assert ring_widget.registered_slot is None + + +def test_ring_has_default_config_values(ring_widget): + assert ring_widget.config.direction == -1 + assert ring_widget.config.line_width == 20 + assert ring_widget.config.start_position == 90 + assert ring_widget.config.min_value == 0 + assert ring_widget.config.max_value == 100 + assert ring_widget.config.precision == 3 + + +################################### +# set_update method tests +################################### + + +def test_set_update_to_manual(ring_widget): + # Start in manual mode + assert ring_widget.config.mode == "manual" + + # Set to manual again (should return early) + ring_widget.set_update("manual") + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_to_scan(ring_widget): + # Mock the dispatcher to avoid actual connections + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + # Set to scan mode + ring_widget.set_update("scan") + + assert ring_widget.config.mode == "scan" + # Verify that connect_slot was called + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_scan_progress + assert "scan_progress" in str(call_args[0][1]) + + +def test_set_update_from_scan_to_manual(ring_widget): + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to scan mode first + ring_widget.set_update("scan") + assert ring_widget.config.mode == "scan" + + # Now switch back to manual + ring_widget.set_update("manual") + + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_to_device(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + # Set to device mode + test_device = "samx" + ring_widget.set_update("device", device=test_device) + + assert ring_widget.config.mode == "device" + assert ring_widget.config.device == test_device + assert ring_widget.config.signal == "samx" + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + + +def test_set_update_from_device_to_manual(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode first + ring_widget.set_update("device", device="samx") + assert ring_widget.config.mode == "device" + + # Switch to manual + ring_widget.set_update("manual") + + assert ring_widget.config.mode == "manual" + assert ring_widget.registered_slot is None + + +def test_set_update_scan_to_device(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to scan mode first + ring_widget.set_update("scan") + assert ring_widget.config.mode == "scan" + + # Switch to device mode + ring_widget.set_update("device", device="samx") + + assert ring_widget.config.mode == "device" + assert ring_widget.config.device == "samx" + + +def test_set_update_device_to_scan(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode first + ring_widget.set_update("device", device="samx") + assert ring_widget.config.mode == "device" + + # Switch to scan mode + ring_widget.set_update("scan") + + assert ring_widget.config.mode == "scan" + + +def test_set_update_same_device_resubscribes(ring_widget_with_device): + ring_widget = ring_widget_with_device + # Mock the dispatcher + ring_widget.bec_dispatcher.connect_slot = MagicMock() + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + # Set to device mode + test_device = "samx" + ring_widget.set_update("device", device=test_device) + + # Reset mocks + ring_widget.bec_dispatcher.connect_slot.reset_mock() + ring_widget.bec_dispatcher.disconnect_slot.reset_mock() + + # Set to same device mode again (should resubscribe) + ring_widget.set_update("device", device=test_device) + + # Should disconnect and reconnect + ring_widget.bec_dispatcher.disconnect_slot.assert_called_once() + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + + +def test_set_update_invalid_mode(ring_widget): + with pytest.raises(ValueError) as excinfo: + ring_widget.set_update("invalid_mode") + + assert "Unsupported mode: invalid_mode" in str(excinfo.value) + + +################################### +# Value and property tests +################################### + + +def test_set_value(ring_widget): + ring_widget.set_value(42.5) + assert ring_widget.config.value == 42.5 + + +def test_set_value_with_min_max_clamping(ring_widget): + ring_widget.set_min_max_values(0, 100) + + # Set value above max + ring_widget.set_value(150) + assert ring_widget.config.value == 100 + + # Set value below min + ring_widget.set_value(-10) + assert ring_widget.config.value == 0 + + +def test_set_precision(ring_widget): + ring_widget.set_precision(2) + assert ring_widget.config.precision == 2 + + ring_widget.set_value(10.12345) + assert ring_widget.config.value == 10.12 + + +def test_set_min_max_values(ring_widget): + ring_widget.set_min_max_values(10, 90) + + assert ring_widget.config.min_value == 10 + assert ring_widget.config.max_value == 90 + + +def test_set_line_width(ring_widget): + ring_widget.set_line_width(25) + assert ring_widget.config.line_width == 25 + + +def test_set_start_angle(ring_widget): + ring_widget.set_start_angle(180) + assert ring_widget.config.start_position == 180 + + +################################### +# Color management tests +################################### + + +def test_set_color(ring_widget): + test_color = (255, 128, 64, 255) + ring_widget.set_color(test_color) + + # Color is stored as hex string internally + assert ring_widget.color.getRgb() == test_color + + +def test_set_color_with_link_colors_updates_background(ring_widget): + # Enable color linking + ring_widget.config.link_colors = True + + # Store original background + original_bg = ring_widget.background_color.getRgb() + + test_color = (255, 100, 50, 255) + ring_widget.set_color(test_color) + + # Background should be derived using subtle_background_color + bg_color = ring_widget.background_color + # Background should have changed + assert bg_color.getRgb() != original_bg + # Background should be different from the main color + assert bg_color.getRgb() != test_color + + +def test_set_background_when_colors_unlinked(ring_widget): + # Disable color linking + ring_widget.config.link_colors = False + + test_bg = (100, 100, 100, 128) + ring_widget.set_background(test_bg) + + assert ring_widget.background_color.getRgb() == test_bg + + +def test_set_background_when_colors_linked_does_nothing(ring_widget): + # Enable color linking + ring_widget.config.link_colors = True + + original_bg = ring_widget.background_color.getRgb() + test_bg = (100, 100, 100, 128) + + ring_widget.set_background(test_bg) + + # Background should not change when colors are linked + assert ring_widget.background_color.getRgb() == original_bg + + +def test_color_link_derives_background(ring_widget): + ring_widget.config.link_colors = True + + bright_color = QColor(255, 255, 0, 255) # Bright yellow + original_bg = ring_widget.background_color.getRgb() + + ring_widget.set_color(bright_color.getRgb()) + + # Get the derived background color + bg_color = ring_widget.background_color + + # Background should have changed + assert bg_color.getRgb() != original_bg + # Background should be a subtle blend, not the same as the main color + assert bg_color.getRgb() != bright_color.getRgb() + + +def test_convert_color_from_tuple(ring_widget): + color_tuple = (200, 150, 100, 255) + qcolor = ring_widget.convert_color(color_tuple) + + assert isinstance(qcolor, QColor) + assert qcolor.getRgb() == color_tuple + + +def test_convert_color_from_hex_string(ring_widget): + hex_color = "#FF8040FF" + qcolor = ring_widget.convert_color(hex_color) + + assert isinstance(qcolor, QColor) + assert qcolor.isValid() + + +################################### +# Gap property tests +################################### + + +def test_gap_property(ring_widget): + ring_widget.gap = 15 + assert ring_widget.gap == 15 + + +################################### +# Config validation tests +################################### + + +def test_config_default_values(): + config = ProgressbarConfig() + + assert config.value == 0 + assert config.direction == -1 + assert config.line_width == 20 + assert config.start_position == 90 + assert config.min_value == 0 + assert config.max_value == 100 + assert config.precision == 3 + assert config.mode == "manual" + assert config.link_colors is True + + +def test_config_with_custom_values(): + config = ProgressbarConfig( + value=50, direction=1, line_width=20, min_value=10, max_value=90, precision=2, mode="scan" + ) + + assert config.value == 50 + assert config.direction == 1 + assert config.line_width == 20 + assert config.min_value == 10 + assert config.max_value == 90 + assert config.precision == 2 + assert config.mode == "scan" + + +################################### +# set_direction tests +################################### + + +def test_set_direction_clockwise(ring_widget): + ring_widget.set_direction(-1) + assert ring_widget.config.direction == -1 + + +def test_set_direction_counter_clockwise(ring_widget): + ring_widget.set_direction(1) + assert ring_widget.config.direction == 1 + + +################################### +# _update_device_connection tests +################################### + + +def test_update_device_connection_with_progress_signal(ring_widget_with_device): + ring_widget = ring_widget_with_device + samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx + samx._info["signals"]["progress"] = { + "obj_name": "samx_progress", + "component_name": "progress", + "signal_class": "ProgressSignal", + "kind_str": "hinted", + } + + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + ring_widget._update_device_connection("samx", "progress") + + # Should connect to device_progress endpoint + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_device_progress + + +def test_update_device_connection_with_hinted_signal(ring_widget): + mock_device = FakeDevice(name="samx") + mock_device._info = { + "signals": { + "samx": {"obj_name": "samx", "signal_class": "SomeOtherSignal", "kind_str": "hinted"} + } + } + + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + + ring_widget.bec_dispatcher.connect_slot = MagicMock() + + ring_widget._update_device_connection("samx", "samx") + + # Should connect to device_readback endpoint + ring_widget.bec_dispatcher.connect_slot.assert_called_once() + call_args = ring_widget.bec_dispatcher.connect_slot.call_args + assert call_args[0][0] == ring_widget.on_device_readback + + +def test_update_device_connection_no_device_manager(ring_widget): + ring_widget.bec_dispatcher.client.device_manager = None + + with pytest.raises(ValueError) as excinfo: + ring_widget._update_device_connection("samx", "signal") + assert "Device manager is not available" in str(excinfo.value) + + +def test_update_device_connection_device_not_found(ring_widget): + mock_device = FakeDevice(name="samx") + ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device + + # Should return without raising an error + ring_widget._update_device_connection("nonexistent", "signal") + + +################################### +# on_scan_progress tests +################################### + + +def test_on_scan_progress_updates_value(ring_widget): + msg = {"value": 42, "max_value": 100} + meta = {"RID": "test_rid_123"} + + ring_widget.on_scan_progress(msg, meta) + + assert ring_widget.config.value == 42 + + +def test_on_scan_progress_updates_min_max_on_new_rid(ring_widget): + msg = {"value": 50, "max_value": 200} + meta = {"RID": "new_rid"} + + ring_widget.RID = "old_rid" + ring_widget.on_scan_progress(msg, meta) + + assert ring_widget.config.min_value == 0 + assert ring_widget.config.max_value == 200 + assert ring_widget.config.value == 50 + + +def test_on_scan_progress_same_rid_no_min_max_update(ring_widget): + msg = {"value": 75, "max_value": 300} + meta = {"RID": "same_rid"} + + ring_widget.RID = "same_rid" + ring_widget.set_min_max_values(0, 100) + + ring_widget.on_scan_progress(msg, meta) + + # Max value should not be updated when RID is the same + assert ring_widget.config.max_value == 100 + assert ring_widget.config.value == 75 + + +################################### +# on_device_readback tests +################################### + + +def test_on_device_readback_updates_value(ring_widget): + ring_widget.config.device = "samx" + ring_widget.config.signal = "readback" + + msg = {"signals": {"readback": {"value": 12.34}}} + meta = {} + + ring_widget.on_device_readback(msg, meta) + + assert ring_widget.config.value == 12.34 + + +def test_on_device_readback_uses_device_name_when_no_signal(ring_widget): + ring_widget.config.device = "samy" + ring_widget.config.signal = None + + msg = {"signals": {"samy": {"value": 56.78}}} + meta = {} + + ring_widget.on_device_readback(msg, meta) + + assert ring_widget.config.value == 56.78 + + +def test_on_device_readback_no_device_returns_early(ring_widget): + ring_widget.config.device = None + + msg = {"signals": {"samx": {"value": 99.99}}} + meta = {} + + initial_value = ring_widget.config.value + ring_widget.on_device_readback(msg, meta) + + # Value should not change + assert ring_widget.config.value == initial_value + + +def test_on_device_readback_missing_signal_data(ring_widget): + ring_widget.config.device = "samx" + ring_widget.config.signal = "missing_signal" + + msg = {"signals": {"other_signal": {"value": 11.11}}} + meta = {} + + initial_value = ring_widget.config.value + ring_widget.on_device_readback(msg, meta) + + # Value should not change when signal is missing + assert ring_widget.config.value == initial_value + + +################################### +# on_device_progress tests +################################### + + +def test_on_device_progress_updates_value_and_max(ring_widget): + ring_widget.config.device = "samx" + + msg = {"value": 30, "max_value": 150, "done": False} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + assert ring_widget.config.value == 30 + assert ring_widget.config.max_value == 150 + + +def test_on_device_progress_done_sets_to_max(ring_widget): + ring_widget.config.device = "samx" + + msg = {"value": 80, "max_value": 100, "done": True} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + # When done is True, value should be set to max_value + assert ring_widget.config.value == 100 + assert ring_widget.config.max_value == 100 + + +def test_on_device_progress_no_device_returns_early(ring_widget): + ring_widget.config.device = None + + msg = {"value": 50, "max_value": 100, "done": False} + meta = {} + + initial_value = ring_widget.config.value + initial_max = ring_widget.config.max_value + + ring_widget.on_device_progress(msg, meta) + + # Nothing should change + assert ring_widget.config.value == initial_value + assert ring_widget.config.max_value == initial_max + + +def test_on_device_progress_default_values(ring_widget): + ring_widget.config.device = "samx" + + # Message without value and max_value + msg = {} + meta = {} + + ring_widget.on_device_progress(msg, meta) + + # Should use defaults: value=0, max_value=100 + assert ring_widget.config.value == 0 + assert ring_widget.config.max_value == 100 diff --git a/tests/unit_tests/test_ring_progress_settings.py b/tests/unit_tests/test_ring_progress_settings.py new file mode 100644 index 000000000..341e7ade2 --- /dev/null +++ b/tests/unit_tests/test_ring_progress_settings.py @@ -0,0 +1,66 @@ +import pytest + +from bec_widgets.utils.settings_dialog import SettingsDialog +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings +from tests.unit_tests.client_mocks import mocked_client + + +@pytest.fixture +def ring_progress_bar_widget(qtbot, mocked_client): + widget = RingProgressBar(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def rpb_settings_dialog(qtbot, ring_progress_bar_widget): + settings = RingSettings( + parent=ring_progress_bar_widget, target_widget=ring_progress_bar_widget, popup=True + ) + dialog = SettingsDialog( + ring_progress_bar_widget, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +@pytest.fixture +def rpb_settings_dialog_with_rings(qtbot, ring_progress_bar_widget): + ring_progress_bar_widget.add_ring() + ring_progress_bar_widget.add_ring() + settings = RingSettings( + parent=ring_progress_bar_widget, target_widget=ring_progress_bar_widget, popup=True + ) + dialog = SettingsDialog( + ring_progress_bar_widget, + settings_widget=settings, + window_title="Ring Progress Bar Settings", + modal=False, + ) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +def test_ring_progress_settings_dialog_opens(rpb_settings_dialog): + """Test that the Ring Progress Bar settings dialog opens correctly.""" + dialog = rpb_settings_dialog + dialog.show() + assert dialog.isVisible() + assert dialog.windowTitle() == "Ring Progress Bar Settings" + dialog.accept() + + +def test_ring_progress_settings_dialog_with_rings(rpb_settings_dialog_with_rings): + """Test that the Ring Progress Bar settings dialog opens correctly with rings.""" + dialog = rpb_settings_dialog_with_rings + dialog.show() + assert dialog.isVisible() + assert dialog.windowTitle() == "Ring Progress Bar Settings" + dialog.accept() # Close the dialog From e5b76bc855c251d96b38166efbe6d02794e23cd8 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 21 Jan 2026 21:58:21 +0100 Subject: [PATCH 133/155] fix(rpc_server): use single shot instead of processEvents to avoid dead locks --- bec_widgets/utils/rpc_server.py | 79 +++++++++++++++++--- tests/unit_tests/test_rpc_server.py | 108 ++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index a8026aaad..ed53f78cf 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -1,7 +1,6 @@ from __future__ import annotations import functools -import time import traceback import types from contextlib import contextmanager @@ -12,7 +11,6 @@ from bec_lib.logger import bec_logger from bec_lib.utils.import_utils import lazy_import from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import QApplication from redis.exceptions import RedisError from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -32,6 +30,10 @@ T = TypeVar("T") +class RegistryNotReadyError(Exception): + """Raised when trying to access an object from the RPC registry that is not yet registered.""" + + @contextmanager def rpc_exception_hook(err_func): """This context replaces the popup message box for error display with a specific hook""" @@ -55,6 +57,19 @@ def custom_exception_hook(self, exc_type, value, tb, **kwargs): popup.custom_exception_hook = old_exception_hook +class SingleshotRPCRepeat: + + def __init__(self, max_delay: int = 2000): + self.max_delay = max_delay + self.accumulated_delay = 0 + + def __iadd__(self, delay: int): + self.accumulated_delay += delay + if self.accumulated_delay > self.max_delay: + raise RegistryNotReadyError("Max delay exceeded for RPC singleshot repeat") + return self + + class RPCServer: client: BECClient @@ -86,6 +101,7 @@ def __init__( self._heartbeat_timer.start(200) self._registry_update_callbacks = [] self._broadcasted_data = {} + self._rpc_singleshot_repeats: dict[str, SingleshotRPCRepeat] = {} self.status = messages.BECStatus.RUNNING logger.success(f"Server started with gui_id: {self.gui_id}") @@ -109,7 +125,8 @@ def on_rpc_update(self, msg: dict, metadata: dict): self.send_response(request_id, False, {"error": content}) else: logger.debug(f"RPC instruction executed successfully: {res}") - self.send_response(request_id, True, {"result": res}) + self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res)) def send_response(self, request_id: str, accepted: bool, msg: dict): self.client.connector.set_and_publish( @@ -167,14 +184,61 @@ def run_rpc(self, obj, method, args, kwargs): res = None else: res = method_obj(*args, **kwargs) + return res + + def serialize_result_and_send(self, request_id: str, res: object): + """ + Serialize the result of an RPC call and send it back to the client. + Note: If the object is not yet registered in the RPC registry, this method + will retry serialization after a short delay, up to a maximum delay. In order + to avoid processEvents calls in the middle of serialization, QTimer.singleShot is used. + This allows the target event to 'float' to the next event loop iteration until the + object is registered. + The 'jump' to the next event loop is indicated by raising a RegistryNotReadyError, see + _serialize_bec_connector. + + Args: + request_id (str): The ID of the request. + res (object): The result of the RPC call. + """ + retry_delay = 100 + try: if isinstance(res, list): res = [self.serialize_object(obj) for obj in res] elif isinstance(res, dict): res = {key: self.serialize_object(val) for key, val in res.items()} else: res = self.serialize_object(res) - return res + except RegistryNotReadyError: + try: + self._rpc_singleshot_repeats[request_id] += retry_delay + QTimer.singleShot( + retry_delay, lambda: self.serialize_result_and_send(request_id, res) + ) + except RegistryNotReadyError: + logger.error( + f"Max delay exceeded for RPC request {request_id}, sending error response" + ) + self.send_response( + request_id, + False, + { + "error": f"Max delay exceeded for RPC request {request_id}, object not registered in time." + }, + ) + self._rpc_singleshot_repeats.pop(request_id, None) + return + except Exception as exc: + logger.error(f"Error while serializing RPC result: {exc}") + self.send_response( + request_id, + False, + {"error": f"Error while serializing RPC result: {exc}\n{traceback.format_exc()}"}, + ) + else: + self.send_response(request_id, True, {"result": res}) + self._rpc_singleshot_repeats.pop(request_id, None) def serialize_object(self, obj: T) -> None | dict | T: """ @@ -256,11 +320,8 @@ def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict: except Exception: container_proxy = None - if wait: - while not self.rpc_register.object_is_registered(connector): - QApplication.processEvents() - logger.info(f"Waiting for {connector} to be registered...") - time.sleep(0.1) + if wait and not self.rpc_register.object_is_registered(connector): + raise RegistryNotReadyError(f"Connector {connector} not registered yet") widget_class = getattr(connector, "rpc_widget_class", None) if not widget_class: diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index fa9e0b55b..b4ecf906a 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -1,9 +1,28 @@ import argparse +from unittest.mock import patch import pytest from bec_lib.service_config import ServiceConfig +from qtpy.QtWidgets import QWidget from bec_widgets.cli.server import GUIServer +from bec_widgets.utils.bec_connector import BECConnector +from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat + +from .client_mocks import mocked_client + + +class DummyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + self.setObjectName("DummyWidget") + + +@pytest.fixture +def dummy_widget(qtbot, mocked_client): + widget = DummyWidget(client=mocked_client) + qtbot.addWidget(widget) + return widget @pytest.fixture @@ -14,6 +33,13 @@ def gui_server(): return GUIServer(args=args) +@pytest.fixture +def rpc_server(mocked_client): + rpc_server = RPCServer(gui_id="test_gui", client=mocked_client) + yield rpc_server + rpc_server.shutdown() + + def test_gui_server_start_server_without_service_config(gui_server): """ Test that the server is started with the correct arguments. @@ -30,3 +56,85 @@ def test_gui_server_get_service_config(gui_server): Test that the server is started with the correct arguments. """ assert gui_server._get_service_config().config == ServiceConfig().config + + +def test_singleshot_rpc_repeat_raises_on_repeated_singleshot(rpc_server): + """ + Test that a singleshot RPC method raises an error when called multiple times. + """ + repeat = SingleshotRPCRepeat() + rpc_server._rpc_singleshot_repeats["test_method"] = repeat + + repeat += 100 # First call should work fine + with pytest.raises(RegistryNotReadyError): + repeat += 2000 # Should raise here + + +def test_serialize_result_and_send_with_singleshot_retry(rpc_server, qtbot, dummy_widget): + """ + Test that serialize_result_and_send retries when RegistryNotReadyError is raised, + and eventually succeeds when the object becomes registered. + """ + request_id = "test_request_123" + + dummy = dummy_widget + + # Track how many times serialize_object is called + call_count = 0 + + def serialize_side_effect(obj): + nonlocal call_count + call_count += 1 + # First 2 calls raise RegistryNotReadyError + if call_count <= 2: + raise RegistryNotReadyError(f"Not ready yet (call {call_count})") + # Third call succeeds + return {"gui_id": dummy.gui_id, "success": True} + + # Patch serialize_object to control when it raises RegistryNotReadyError + with patch.object(rpc_server, "serialize_object", side_effect=serialize_side_effect): + with patch.object(rpc_server, "send_response") as mock_send_response: + # Start the serialization process + rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + rpc_server.serialize_result_and_send(request_id, dummy) + + # Verify that serialize_object was called 3 times + qtbot.waitUntil(lambda: call_count >= 3, timeout=5000) + + # Verify that send_response was called with success + mock_send_response.assert_called_once() + args = mock_send_response.call_args[0] + assert args[0] == request_id + assert args[1] is True # accepted=True + assert "result" in args[2] + + +def test_serialize_result_and_send_max_delay_exceeded(rpc_server, qtbot, dummy_widget): + """ + Test that serialize_result_and_send sends an error response when max delay is exceeded. + """ + request_id = "test_request_456" + + dummy = dummy_widget + + # Always raise RegistryNotReadyError + with patch.object( + rpc_server, "serialize_object", side_effect=RegistryNotReadyError("Always not ready") + ): + with patch.object(rpc_server, "send_response") as mock_send_response: + # Start the serialization process + rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat() + rpc_server.serialize_result_and_send(request_id, dummy) + + # Process event loop to allow all singleshot timers to fire + # Max delay is 2000ms, with 100ms retry intervals = ~20 retries + # Wait for the max delay plus some buffer + qtbot.wait(2500) + + # Verify that send_response was called with an error + mock_send_response.assert_called() + args = mock_send_response.call_args[0] + assert args[0] == request_id + assert args[1] is False # accepted=False + assert "error" in args[2] + assert "Max delay exceeded" in args[2]["error"] From 4bb8e8650969c2e194cf57090db9a89f44ebb8c9 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 21 Jan 2026 22:39:03 +0100 Subject: [PATCH 134/155] test(e2e): raise with widget name --- tests/end-2-end/test_rpc_widgets_e2e.py | 65 +++++++++++++------------ 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/tests/end-2-end/test_rpc_widgets_e2e.py b/tests/end-2-end/test_rpc_widgets_e2e.py index 185adfd5e..2a81168f9 100644 --- a/tests/end-2-end/test_rpc_widgets_e2e.py +++ b/tests/end-2-end/test_rpc_widgets_e2e.py @@ -120,35 +120,38 @@ def test_available_widgets(qtbot, connected_client_gui_obj): ############################# ####### Remove widget ####### ############################# - - # Now we remove the widget again - widget_id = widget._gui_id - widget.remove() - # Wait for namespace to change - wait_for_namespace_change( - qtbot, gui, dock_area, widget.object_name, widget_id, exists=False - ) - # Assert that widget is removed from the ipython registry and the namespace - assert hasattr(dock_area, widget.object_name) is False - # Client registry - assert gui._ipython_registry.get(widget_id, None) is None - # Server registry - assert gui._server_registry.get(widget_id, None) is None - - # Check that the number of top level widgets is still the same. As the cleanup is done by the - # qt event loop, we need to wait for the qtbot to finish the cleanup try: - qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count) - except Exception as exc: - raise RuntimeError( - f"Widget {object_name} was not removed properly. The number of top level widgets " - f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following " - f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}" - ) from exc - # Number of widgets with parent_id == None, should be 2 - widgets = [ - widget - for widget in gui._server_registry.values() - if widget["config"]["parent_id"] is None - ] - assert len(widgets) == 2 + # Now we remove the widget again + widget_id = widget._gui_id + widget.remove() + # Wait for namespace to change + wait_for_namespace_change( + qtbot, gui, dock_area, widget.object_name, widget_id, exists=False + ) + # Assert that widget is removed from the ipython registry and the namespace + assert hasattr(dock_area, widget.object_name) is False + # Client registry + assert gui._ipython_registry.get(widget_id, None) is None + # Server registry + assert gui._server_registry.get(widget_id, None) is None + + # Check that the number of top level widgets is still the same. As the cleanup is done by the + # qt event loop, we need to wait for the qtbot to finish the cleanup + try: + qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count) + except Exception as exc: + raise RuntimeError( + f"Widget {object_name} was not removed properly. The number of top level widgets " + f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following " + f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}" + ) from exc + # Number of widgets with parent_id == None, should be 2 + widgets = [ + widget + for widget in gui._server_registry.values() + if widget["config"]["parent_id"] is None + ] + assert len(widgets) == 2 + + except Exception as e: + raise RuntimeError(f"Failed to remove widget {object_name}") from e From de09cc660adea79acd27ea9410196707e3ac6a05 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 20 Jan 2026 12:05:16 +0100 Subject: [PATCH 135/155] feat(SafeProperty): SafeProperty emits property_changed signal --- bec_widgets/utils/error_popups.py | 208 +++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 7 deletions(-) diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 7d52af4b9..efa270382 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -1,19 +1,38 @@ import functools import sys import traceback +from typing import Any, Callable, Literal import shiboken6 from bec_lib.logger import bec_logger from louie.saferef import safe_ref from qtpy.QtCore import Property, QObject, Qt, Signal, Slot -from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QApplication, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) logger = bec_logger.logger RAISE_ERROR_DEFAULT = False -def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs): +def SafeProperty( + prop_type, + *prop_args, + popup_error: bool = False, + default: Any = None, + auto_emit: bool = False, + emit_value: Literal["stored", "input"] | Callable[[object, object], object] = "stored", + emit_on_change: bool = True, + **prop_kwargs, +): """ Decorator to create a Qt Property with safe getter and setter so that Qt Designer won't crash if an exception occurs in either method. @@ -22,7 +41,15 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, prop_type: The property type (e.g., str, bool, int, custom classes, etc.) popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently. default: Any default/fallback value to return if the getter raises an exception. - *prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor. + auto_emit (bool): If True, automatically emit property_changed signal when setter is called. + Requires the widget to have a property_changed signal (str, object). + Note: This is different from Qt's 'notify' parameter which expects a Signal. + emit_value: Controls which value is emitted when auto_emit=True. + - "stored" (default): emit the value from the getter after setter runs + - "input": emit the raw setter input + - callable: called as emit_value(self_, value) after setter and must return the value to emit + emit_on_change (bool): If True, emit only when the stored value changes. + *prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor (check https://doc.qt.io/qt-6/properties.html). Usage: @SafeProperty(int, default=-1) @@ -34,6 +61,41 @@ def some_value(self) -> int: def some_value(self, val: int): # your setter logic ... + + # With auto-emit for toolbar sync: + @SafeProperty(bool, auto_emit=True) + def fft(self) -> bool: + return self._fft + + @fft.setter + def fft(self, value: bool): + self._fft = value + # property_changed.emit("fft", value) is called automatically + + # With custom emit modes: + @SafeProperty(int, auto_emit=True, emit_value="stored") + def precision_stored(self) -> int: + return self._precision_stored + + @precision_stored.setter + def precision_stored(self, value: int): + self._precision_stored = max(0, int(value)) + + @SafeProperty(int, auto_emit=True, emit_value="input") + def precision_input(self) -> int: + return self._precision_input + + @precision_input.setter + def precision_input(self, value: int): + self._precision_input = max(0, int(value)) + + @SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10) + def precision_callable(self) -> int: + return self._precision_callable + + @precision_callable.setter + def precision_callable(self, value: int): + self._precision_callable = max(0, int(value)) """ def decorator(py_getter): @@ -70,8 +132,42 @@ def setter(self, setter_func): @functools.wraps(setter_func) def safe_setter(self_, value): try: - return setter_func(self_, value) - except Exception: + before_value = None + if auto_emit and emit_on_change: + try: + before_value = self.getter_func(self_) + except Exception as e: + logger.warning( + f"SafeProperty could not get 'before' value for change detection: {e}" + ) + before_value = None + + result = setter_func(self_, value) + + # Auto-emit property_changed if auto_emit=True and signal exists + if auto_emit and hasattr(self_, "property_changed"): + prop_name = py_getter.__name__ + try: + if callable(emit_value): + emit_payload = emit_value(self_, value) + elif emit_value == "input": + emit_payload = value + else: + emit_payload = self.getter_func(self_) + + if emit_on_change and before_value == emit_payload: + return result + + self_.property_changed.emit(prop_name, emit_payload) + except Exception as notify_error: + # Don't fail the setter if notification fails + logger.warning( + f"SafeProperty auto_emit failed for '{prop_name}': {notify_error}" + ) + + return result + except Exception as e: + logger.warning(f"SafeProperty setter caught exception: {e}") prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}" error_msg = traceback.format_exc() @@ -337,6 +433,100 @@ def ErrorPopupUtility(): return _popup_utility_instance +class SafePropertyExampleWidget(QWidget): # pragma: no cover + """ + Example widget showcasing SafeProperty auto_emit modes. + """ + + property_changed = Signal(str, object) + + def __init__(self): + super().__init__() + self.setWindowTitle("SafeProperty auto_emit example") + self._precision_stored = 0 + self._precision_input = 0 + self._precision_callable = 0 + + layout = QVBoxLayout(self) + self.status = QLabel("last emit: ", self) + + self.spinbox_stored = QSpinBox(self) + self.spinbox_stored.setRange(-5, 10) + self.spinbox_stored.setValue(0) + self.label_stored = QLabel("stored emit: ", self) + + self.spinbox_input = QSpinBox(self) + self.spinbox_input.setRange(-5, 10) + self.spinbox_input.setValue(0) + self.label_input = QLabel("input emit: ", self) + + self.spinbox_callable = QSpinBox(self) + self.spinbox_callable.setRange(-5, 10) + self.spinbox_callable.setValue(0) + self.label_callable = QLabel("callable emit: ", self) + + layout.addWidget(QLabel("stored emit (normalized value):", self)) + layout.addWidget(self.spinbox_stored) + layout.addWidget(self.label_stored) + + layout.addWidget(QLabel("input emit (raw setter input):", self)) + layout.addWidget(self.spinbox_input) + layout.addWidget(self.label_input) + + layout.addWidget(QLabel("callable emit (custom mapping):", self)) + layout.addWidget(self.spinbox_callable) + layout.addWidget(self.label_callable) + + layout.addWidget(self.status) + + self.spinbox_stored.valueChanged.connect(self._on_spinbox_stored) + self.spinbox_input.valueChanged.connect(self._on_spinbox_input) + self.spinbox_callable.valueChanged.connect(self._on_spinbox_callable) + self.property_changed.connect(self._on_property_changed) + + @SafeProperty(int, auto_emit=True, emit_value="stored", doc="Clamped precision value.") + def precision_stored(self) -> int: + return self._precision_stored + + @precision_stored.setter + def precision_stored(self, value: int): + self._precision_stored = max(0, int(value)) + + @SafeProperty(int, auto_emit=True, emit_value="input", doc="Emit raw input value.") + def precision_input(self) -> int: + return self._precision_input + + @precision_input.setter + def precision_input(self, value: int): + self._precision_input = max(0, int(value)) + + @SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10) + def precision_callable(self) -> int: + return self._precision_callable + + @precision_callable.setter + def precision_callable(self, value: int): + self._precision_callable = max(0, int(value)) + + def _on_spinbox_stored(self, value: int): + self.precision_stored = value + + def _on_spinbox_input(self, value: int): + self.precision_input = value + + def _on_spinbox_callable(self, value: int): + self.precision_callable = value + + def _on_property_changed(self, prop_name: str, value): + self.status.setText(f"last emit: {prop_name}={value}") + if prop_name == "precision_stored": + self.label_stored.setText(f"stored emit: {value}") + elif prop_name == "precision_input": + self.label_input.setText(f"input emit: {value}") + elif prop_name == "precision_callable": + self.label_callable.setText(f"callable emit: {value}") + + class ExampleWidget(QWidget): # pragma: no cover """ Example widget to demonstrate error handling with the ErrorPopupUtility. @@ -391,6 +581,10 @@ def trigger_warning(self): if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) - widget = ExampleWidget() - widget.show() + tabs = QTabWidget() + tabs.setWindowTitle("Error Popups & SafeProperty Examples") + tabs.addTab(ExampleWidget(), "Error Popups") + tabs.addTab(SafePropertyExampleWidget(), "SafeProperty auto_emit") + tabs.resize(420, 520) + tabs.show() sys.exit(app.exec_()) From 3b7bad85d316cb8bb5336f633ad9fc660e7f3ec6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 20 Jan 2026 12:05:16 +0100 Subject: [PATCH 136/155] feat(toolbar): toolbar can be synced with the property_changed for toggle actions --- bec_widgets/utils/toolbars/connections.py | 120 ++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/bec_widgets/utils/toolbars/connections.py b/bec_widgets/utils/toolbars/connections.py index 50b6a1e55..6986c6d35 100644 --- a/bec_widgets/utils/toolbars/connections.py +++ b/bec_widgets/utils/toolbars/connections.py @@ -1,18 +1,136 @@ from __future__ import annotations from abc import abstractmethod +from typing import Callable +from bec_lib.logger import bec_logger from qtpy.QtCore import QObject +logger = bec_logger.logger + class BundleConnection(QObject): + """ + Base class for toolbar bundle connections. + + Provides infrastructure for bidirectional property-toolbar synchronization: + - Toolbar actions → Widget properties (via action.triggered connections) + - Widget properties → Toolbar actions (via property_changed signal) + """ + bundle_name: str + def __init__(self, parent=None): + super().__init__(parent) + self._property_sync_methods: dict[str, Callable] = {} + self._property_sync_connected = False + + def register_property_sync(self, prop_name: str, sync_method: Callable): + """ + Register a method to synchronize toolbar state when a property changes. + + This enables automatic toolbar updates when properties are set programmatically, + restored from QSettings, or changed via RPC. + + Args: + prop_name: The property name to watch (e.g., "fft", "log", "x_grid") + sync_method: Method to call when property changes. Should accept the new value + and update toolbar state (typically with signals blocked to prevent loops) + + Example: + def _sync_fft_toolbar(self, value: bool): + self.fft_action.blockSignals(True) + self.fft_action.setChecked(value) + self.fft_action.blockSignals(False) + + self.register_property_sync("fft", self._sync_fft_toolbar) + """ + self._property_sync_methods[prop_name] = sync_method + + def _resolve_action(self, action_like): + if hasattr(action_like, "action"): + return action_like.action + return action_like + + def register_checked_action_sync(self, prop_name: str, action_like): + """ + Register a property sync for a checkable QAction (or wrapper with .action). + + This reduces boilerplate for simple boolean → checked state updates. + """ + qt_action = self._resolve_action(action_like) + + def _sync_checked(value): + qt_action.blockSignals(True) + try: + qt_action.setChecked(bool(value)) + finally: + qt_action.blockSignals(False) + + self.register_property_sync(prop_name, _sync_checked) + + def connect_property_sync(self, target_widget): + """ + Connect to target widget's property_changed signal for automatic toolbar sync. + + Call this in your connect() method after registering all property syncs. + + Args: + target_widget: The widget to monitor for property changes + """ + if self._property_sync_connected: + return + + if hasattr(target_widget, "property_changed"): + target_widget.property_changed.connect(self._on_property_changed) + self._property_sync_connected = True + else: + logger.warning( + f"{target_widget.__class__.__name__} does not have property_changed signal. " + "Property-toolbar sync will not work." + ) + + def disconnect_property_sync(self, target_widget): + """ + Disconnect from target widget's property_changed signal. + + Call this in your disconnect() method. + + Args: + target_widget: The widget to stop monitoring + """ + if not self._property_sync_connected: + return + + if hasattr(target_widget, "property_changed"): + try: + target_widget.property_changed.disconnect(self._on_property_changed) + except (RuntimeError, TypeError): + # Signal already disconnected or connection doesn't exist + pass + self._property_sync_connected = False + + def _on_property_changed(self, prop_name: str, value): + """ + Internal handler for property changes. + + Calls the registered sync method for the changed property. + """ + if prop_name in self._property_sync_methods: + try: + self._property_sync_methods[prop_name](value) + except Exception as e: + logger.error( + f"Error syncing toolbar for property '{prop_name}': {e}", exc_info=True + ) + @abstractmethod def connect(self): """ Connects the bundle to the target widget or application. This method should be implemented by subclasses to define how the bundle interacts with the target. + + Subclasses should call connect_property_sync(target_widget) if property sync is needed. """ @abstractmethod @@ -20,4 +138,6 @@ def disconnect(self): """ Disconnects the bundle from the target widget or application. This method should be implemented by subclasses to define how to clean up connections. + + Subclasses should call disconnect_property_sync(target_widget) if property sync was connected. """ From 24dbb885f6d6cf6b07731d73e10b08d21fa94034 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 20 Jan 2026 12:20:10 +0100 Subject: [PATCH 137/155] feat(plot_base): plot_base, image and heatmap widget adopted to property-toolbar sync --- bec_widgets/widgets/plots/heatmap/heatmap.py | 6 ++-- bec_widgets/widgets/plots/image/image.py | 6 ++-- .../toolbar_components/image_base_actions.py | 24 +++++++++++++- bec_widgets/widgets/plots/plot_base.py | 32 +++++++------------ tests/unit_tests/test_heatmap_widget.py | 18 +++++++++++ tests/unit_tests/test_image_view_next_gen.py | 25 +++++++++++++++ 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index dc80269e9..3266d9b74 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -1453,7 +1453,7 @@ def enforce_interpolation(self, value: bool): # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -1470,7 +1470,7 @@ def fft(self, enable: bool): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -1504,7 +1504,7 @@ def num_rotation_90(self, value: int): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index a3c01be39..69968ac40 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -336,7 +336,7 @@ def _sync_device_selection(self): # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -353,7 +353,7 @@ def fft(self, enable: bool): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -387,7 +387,7 @@ def num_rotation_90(self, value: int): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. diff --git a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py index 7d3579445..6c8d63b6f 100644 --- a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py +++ b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py @@ -300,9 +300,14 @@ def image_processing(components: ToolbarComponents) -> ToolbarBundle: class ImageProcessingConnection(BundleConnection): """ Connection class for the image processing toolbar bundle. + + Provides bidirectional synchronization between toolbar actions and widget properties: + - Toolbar clicks → Update properties + - Property changes → Update toolbar (via property_changed signal) """ def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) self.bundle_name = "image_processing" self.components = components self.target_widget = target_widget @@ -315,7 +320,6 @@ def __init__(self, components: ToolbarComponents, target_widget=None): raise AttributeError( "Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes." ) - super().__init__() self.fft = components.get_action("image_processing_fft") self.log = components.get_action("image_processing_log") self.transpose = components.get_action("image_processing_transpose") @@ -324,6 +328,11 @@ def __init__(self, components: ToolbarComponents, target_widget=None): self.reset = components.get_action("image_processing_reset") self._connected = False + # Register property sync methods for bidirectional sync + self.register_checked_action_sync("fft", self.fft) + self.register_checked_action_sync("log", self.log) + self.register_checked_action_sync("transpose", self.transpose) + @SafeSlot() def toggle_fft(self): checked = self.fft.action.isChecked() @@ -367,8 +376,11 @@ def reset_settings(self): def connect(self): """ Connect the actions to the target widget's methods. + Enables bidirectional sync: toolbar ↔ properties. """ self._connected = True + + # Toolbar → Property connections self.fft.action.triggered.connect(self.toggle_fft) self.log.action.triggered.connect(self.toggle_log) self.transpose.action.triggered.connect(self.toggle_transpose) @@ -376,15 +388,25 @@ def connect(self): self.left.action.triggered.connect(self.rotate_left) self.reset.action.triggered.connect(self.reset_settings) + # Property → Toolbar connections + self.connect_property_sync(self.target_widget) + def disconnect(self): """ Disconnect the actions from the target widget's methods. """ if not self._connected: return + + # Disconnect toolbar → property self.fft.action.triggered.disconnect(self.toggle_fft) self.log.action.triggered.disconnect(self.toggle_log) self.transpose.action.triggered.disconnect(self.toggle_transpose) self.right.action.triggered.disconnect(self.rotate_right) self.left.action.triggered.disconnect(self.rotate_left) self.reset.action.triggered.disconnect(self.reset_settings) + + # Disconnect property → toolbar + self.disconnect_property_sync(self.target_widget) + + self._connected = False diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 908a16dd8..88c234829 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -446,7 +446,7 @@ def set(self, **kwargs): else: logger.warning(f"Property {key} not found.") - @SafeProperty(str, doc="The title of the axes.") + @SafeProperty(str, auto_emit=True, doc="The title of the axes.") def title(self) -> str: """ Set title of the plot. @@ -462,9 +462,8 @@ def title(self, value: str): value(str): The title to set. """ self.plot_item.setTitle(value) - self.property_changed.emit("title", value) - @SafeProperty(str, doc="The text of the x label") + @SafeProperty(str, auto_emit=True, doc="The text of the x label") def x_label(self) -> str: """ The set label for the x-axis. @@ -481,7 +480,6 @@ def x_label(self, value: str): """ self._user_x_label = value self._apply_x_label() - self.property_changed.emit("x_label", self._user_x_label) @property def x_label_suffix(self) -> str: @@ -535,7 +533,7 @@ def _apply_x_label(self): if self.plot_item.getAxis("bottom").isVisible(): self.plot_item.setLabel("bottom", text=final_label) - @SafeProperty(str, doc="The text of the y label") + @SafeProperty(str, auto_emit=True, doc="The text of the y label") def y_label(self) -> str: """ The set label for the y-axis. @@ -551,7 +549,6 @@ def y_label(self, value: str): """ self._user_y_label = value self._apply_y_label() - self.property_changed.emit("y_label", value) @property def y_label_suffix(self) -> str: @@ -772,7 +769,7 @@ def y_max(self, value: float): """ self.y_limits = (self.y_lim[0], value) - @SafeProperty(bool, doc="Show grid on the x-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the x-axis.") def x_grid(self) -> bool: """ Show grid on the x-axis. @@ -788,9 +785,8 @@ def x_grid(self, value: bool): value(bool): The value to set. """ self.plot_item.showGrid(x=value) - self.property_changed.emit("x_grid", value) - @SafeProperty(bool, doc="Show grid on the y-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the y-axis.") def y_grid(self) -> bool: """ Show grid on the y-axis. @@ -806,9 +802,8 @@ def y_grid(self, value: bool): value(bool): The value to set. """ self.plot_item.showGrid(y=value) - self.property_changed.emit("y_grid", value) - @SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set X-axis to log scale if True, linear if False.") def x_log(self) -> bool: """ Set X-axis to log scale if True, linear if False. @@ -824,9 +819,8 @@ def x_log(self, value: bool): value(bool): The value to set. """ self.plot_item.setLogMode(x=value) - self.property_changed.emit("x_log", value) - @SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set Y-axis to log scale if True, linear if False.") def y_log(self) -> bool: """ Set Y-axis to log scale if True, linear if False. @@ -842,9 +836,8 @@ def y_log(self, value: bool): value(bool): The value to set. """ self.plot_item.setLogMode(y=value) - self.property_changed.emit("y_log", value) - @SafeProperty(bool, doc="Show the outer axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show the outer axes of the plot widget.") def outer_axes(self) -> bool: """ Show the outer axes of the plot widget. @@ -863,9 +856,8 @@ def outer_axes(self, value: bool): self.plot_item.showAxis("right", value) self._outer_axes_visible = value - self.property_changed.emit("outer_axes", value) - @SafeProperty(bool, doc="Show inner axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show inner axes of the plot widget.") def inner_axes(self) -> bool: """ Show inner axes of the plot widget. @@ -886,7 +878,6 @@ def inner_axes(self, value: bool): self._inner_axes_visible = value self._apply_x_label() self._apply_y_label() - self.property_changed.emit("inner_axes", value) @SafeProperty(bool, doc="Invert X axis.") def invert_x(self) -> bool: @@ -1110,7 +1101,9 @@ def toggle_crosshair(self) -> None: self.unhook_crosshair() @SafeProperty( - int, doc="Minimum decimal places for crosshair when dynamic precision is enabled." + int, + auto_emit=True, + doc="Minimum decimal places for crosshair when dynamic precision is enabled.", ) def minimal_crosshair_precision(self) -> int: """ @@ -1130,7 +1123,6 @@ def minimal_crosshair_precision(self, value: int): self._minimal_crosshair_precision = value_int if self.crosshair is not None: self.crosshair.min_precision = value_int - self.property_changed.emit("minimal_crosshair_precision", value_int) @SafeSlot() def reset(self) -> None: diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 38a7b67a0..fe8e0ee87 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -834,6 +834,24 @@ def test_device_properties_property_changed_signal(heatmap_widget): mock_handler.assert_any_call("x_device_name", "samx") +def test_auto_emit_syncs_heatmap_toolbar_actions(heatmap_widget): + from unittest.mock import Mock + + fft_action = heatmap_widget.toolbar.components.get_action("image_processing_fft").action + log_action = heatmap_widget.toolbar.components.get_action("image_processing_log").action + + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + heatmap_widget.fft = True + heatmap_widget.log = True + + assert fft_action.isChecked() + assert log_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + + def test_device_entry_validation_with_invalid_device(heatmap_widget): """Test that invalid device names are handled gracefully.""" # Try to set invalid device name diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 78e34705b..480502515 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -249,6 +249,31 @@ def test_toolbar_actions_presence(qtbot, mocked_client): assert bec_image_view.toolbar.components.exists("image_dim_combo") +def test_auto_emit_syncs_image_toolbar_actions(qtbot, mocked_client): + from unittest.mock import Mock + + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + fft_action = bec_image_view.toolbar.components.get_action("image_processing_fft").action + log_action = bec_image_view.toolbar.components.get_action("image_processing_log").action + transpose_action = bec_image_view.toolbar.components.get_action( + "image_processing_transpose" + ).action + + mock_handler = Mock() + bec_image_view.property_changed.connect(mock_handler) + + bec_image_view.fft = True + bec_image_view.log = True + bec_image_view.transpose = True + + assert fft_action.isChecked() + assert log_action.isChecked() + assert transpose_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + mock_handler.assert_any_call("transpose", True) + + def test_image_processing_fft_toggle(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view.fft = True From 8d75c2af1c2eeb768da3c9be0f9bf842e43bd6d6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 19 Jan 2026 22:22:28 +0100 Subject: [PATCH 138/155] feat(signal_combobox): extended that can filter by signal class and dimension of the signal --- bec_widgets/cli/client.py | 6 +- bec_widgets/utils/filter_io.py | 70 +++++ .../base_classes/device_signal_input_base.py | 4 +- .../signal_combobox/signal_combobox.py | 257 ++++++++++++++++-- tests/unit_tests/test_device_signal_input.py | 190 +++++++++++++ tests/unit_tests/test_filter_io.py | 27 ++ 6 files changed, 533 insertions(+), 21 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 7525a8e8f..5a5d359c0 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -5424,9 +5424,11 @@ def set_signal(self, signal: str): """ @rpc_call - def set_device(self, device: str | None): + def set_device(self, device: "str | None"): """ - Set the device. If device is not valid, device will be set to None which happens + Set the device. When signal_class_filter is active, ensures base-class + logic runs and then refreshes the signal list to show only signals from + that device matching the signal class filter. Args: device(str): device name. diff --git a/bec_widgets/utils/filter_io.py b/bec_widgets/utils/filter_io.py index b0f6700d9..1e3a316ee 100644 --- a/bec_widgets/utils/filter_io.py +++ b/bec_widgets/utils/filter_io.py @@ -7,6 +7,7 @@ from bec_lib.logger import bec_logger from qtpy.QtCore import QStringListModel from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit +from typeguard import TypeCheckError from bec_widgets.utils.ophyd_kind_util import Kind @@ -55,6 +56,49 @@ def update_with_kind( """ # This method should be implemented in subclasses or extended as needed + def update_with_bec_signal_class( + self, + signal_class_filter: str | list[str], + client, + ndim_filter: int | list[int] | None = None, + ) -> list[tuple[str, str, dict]]: + """Update the selection based on signal classes using device_manager.get_bec_signals. + + Args: + signal_class_filter (str|list[str]): List of signal class names to filter. + client: BEC client instance. + ndim_filter (int | list[int] | None): Filter signals by dimensionality. + If provided, only signals with matching ndim will be included. + + Returns: + list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples. + """ + if not client or not hasattr(client, "device_manager"): + return [] + + try: + signals = client.device_manager.get_bec_signals(signal_class_filter) + except TypeCheckError as e: + logger.warning(f"Error retrieving signals: {e}") + return [] + + if ndim_filter is None: + return signals + + if isinstance(ndim_filter, int): + ndim_filter = [ndim_filter] + + filtered_signals = [] + for device_name, signal_name, signal_config in signals: + ndim = None + if isinstance(signal_config, dict): + ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim") + + if ndim in ndim_filter: + filtered_signals.append((device_name, signal_name, signal_config)) + + return filtered_signals + class LineEditFilterHandler(WidgetFilterHandler): """Handler for QLineEdit widget""" @@ -255,6 +299,32 @@ def update_with_kind( f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}" ) + @staticmethod + def update_with_signal_class( + widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None + ) -> list[tuple[str, str, dict]]: + """ + Update the selection based on signal classes using device_manager.get_bec_signals. + + Args: + widget: Widget instance. + signal_class_filter (list[str]): List of signal class names to filter. + client: BEC client instance. + ndim_filter (int | list[int] | None): Filter signals by dimensionality. + If provided, only signals with matching ndim will be included. + + Returns: + list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples. + """ + handler_class = FilterIO._find_handler(widget) + if handler_class: + return handler_class().update_with_bec_signal_class( + signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter + ) + raise ValueError( + f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}" + ) + @staticmethod def _find_handler(widget): """ diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py index 4e07b1902..07e993e3d 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py @@ -6,7 +6,7 @@ from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler +from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.widget_io import WidgetIO @@ -17,6 +17,8 @@ class DeviceSignalInputBaseConfig(ConnectionConfig): """Configuration class for DeviceSignalInputBase.""" signal_filter: str | list[str] | None = None + signal_class_filter: list[str] | None = None + ndim_filter: int | list[int] | None = None default: str | None = None arg_name: str | None = None device: str | None = None diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index c895b9db9..dcc8b8794 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -1,7 +1,6 @@ from __future__ import annotations -from bec_lib.device import Positioner -from qtpy.QtCore import QSize, Signal +from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtWidgets import QComboBox, QSizePolicy from bec_widgets.utils.error_popups import SafeProperty, SafeSlot @@ -22,9 +21,17 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): client: BEC client object. config: Device input configuration. gui_id: GUI ID. - device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. + device: Device name to filter signals from. + signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details. + signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown. + ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway. default: Default device name. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. + store_signal_config: Whether to store the full signal config in the combobox item data. + require_device: If True, signals are only shown/validated when a device is set. + Signals: + device_signal_changed: Emitted when the current text represents a valid signal selection. + signal_reset: Emitted when validation fails and the selection should be treated as cleared. """ USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"] @@ -34,6 +41,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): RPC = True device_signal_changed = Signal(str) + signal_reset = Signal() def __init__( self, @@ -42,9 +50,13 @@ def __init__( config: DeviceSignalInputBaseConfig | None = None, gui_id: str | None = None, device: str | None = None, - signal_filter: str | list[str] | None = None, + signal_filter: list[Kind] | None = None, + signal_class_filter: list[str] | None = None, + ndim_filter: int | list[int] | None = None, default: str | None = None, arg_name: str | None = None, + store_signal_config: bool = True, + require_device: bool = False, **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) @@ -57,26 +69,64 @@ def __init__( self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.setMinimumSize(QSize(100, 0)) self._set_first_element_as_empty = True - # We do not consider the config that is passed here, this produced problems - # with QtDesigner, since config and input arguments may differ and resolve properly - # Implementing this logic and config recoverage is postponed. + self._signal_class_filter = signal_class_filter or [] + self._store_signal_config = store_signal_config + self.config.ndim_filter = ndim_filter or None + self._require_device = require_device + self._is_valid_input = False + + # Note: Runtime arguments (e.g. device, default, arg_name) intentionally take + # precedence over values from the passed-in config. Full reconciliation and + # restoration of state between designer-provided config and runtime arguments + # is not yet implemented, as earlier attempts caused issues with QtDesigner. self.currentTextChanged.connect(self.on_text_changed) + + # Kind filtering is always applied; class filtering is additive. If signal_filter is None, + # we default to hinted+normal, even when signal_class_filter is empty or None. To disable + # kinds, pass an explicit signal_filter or toggle include_* after init. if signal_filter is not None: self.set_filter(signal_filter) else: self.set_filter([Kind.hinted, Kind.normal, Kind.config]) + if device is not None: self.set_device(device) if default is not None: self.set_signal(default) + @SafeSlot(str) + def set_device(self, device: str | None): + """ + Set the device. When signal_class_filter is active, ensures base-class + logic runs and then refreshes the signal list to show only signals from + that device matching the signal class filter. + + Args: + device(str): device name. + """ + super().set_device(device) + + if self._signal_class_filter: + # Refresh the signal list to show only this device's signals + self.update_signals_from_signal_classes() + @SafeSlot() @SafeSlot(dict, dict) def update_signals_from_filters( self, content: dict | None = None, metadata: dict | None = None ): - """Update the filters for the combobox""" + """Update the filters for the combobox. + When signal_class_filter is active, skip the normal Kind-based filtering. + + Args: + content (dict | None): Content dictionary from BEC event. + metadata (dict | None): Metadata dictionary from BEC event. + """ super().update_signals_from_filters(content, metadata) + + if self._signal_class_filter: + self.update_signals_from_signal_classes() + return # pylint: disable=protected-access if FilterIO._find_handler(self) is ComboBoxFilterHandler: if len(self._config_signals) > 0: @@ -118,6 +168,63 @@ def set_first_element_as_empty(self, value: bool) -> None: if self.count() > 0 and self.itemText(0) == "": self.removeItem(0) + @SafeProperty("QStringList") + def signal_class_filter(self) -> list[str]: + """ + Get the list of signal classes to filter. + + Returns: + list[str]: List of signal class names to filter. + """ + return self._signal_class_filter + + @signal_class_filter.setter + def signal_class_filter(self, value: list[str] | None): + """ + Set the signal class filter. + + Args: + value (list[str] | None): List of signal class names to filter, or None/empty + to disable class-based filtering and revert to the default behavior. + """ + normalized_value = value or [] + self._signal_class_filter = normalized_value + self.config.signal_class_filter = normalized_value + if self._signal_class_filter: + self.update_signals_from_signal_classes() + else: + self.update_signals_from_filters() + + @SafeProperty(int) + def ndim_filter(self) -> int: + """Dimensionality filter for signals.""" + return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1 + + @ndim_filter.setter + def ndim_filter(self, value: int): + self.config.ndim_filter = None if value < 0 else value + if self._signal_class_filter: + self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter) + + @SafeProperty(bool) + def require_device(self) -> bool: + """ + If True, signals are only shown/validated when a device is set. + + Note: + This property affects list rebuilding only when a signal_class_filter + is active. Without a signal class filter, the available signals are + managed by the standard Kind-based filtering. + """ + return self._require_device + + @require_device.setter + def require_device(self, value: bool): + self._require_device = value + # Rebuild list when toggled, but only when using signal_class_filter + if self._signal_class_filter: + self.update_signals_from_signal_classes() + def set_to_obj_name(self, obj_name: str) -> bool: """ Set the combobox to the object name of the signal. @@ -166,6 +273,91 @@ def get_signal_name(self) -> str: return signal_name if signal_name else "" + def get_signal_config(self) -> dict | None: + """ + Get the signal config from the combobox for the currently selected signal. + + Returns: + dict | None: The signal configuration dictionary or None if not available. + """ + if not self._store_signal_config: + return None + + index = self.currentIndex() + if index == -1: + return None + + signal_info = self.itemData(index) + return signal_info if signal_info else None + + def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None): + """ + Update the combobox with signals filtered by signal classes and optionally by ndim. + Uses device_manager.get_bec_signals() to retrieve signals. + If a device is set, only shows signals from that device. + + Args: + ndim_filter (int | list[int] | None): Filter signals by dimensionality. + If provided, only signals with matching ndim will be included. + Can be a single int or a list of ints. Use None to include all dimensions. + If not provided, uses the previously set ndim_filter. + """ + if not self._signal_class_filter: + return + + if self._require_device and not self._device: + self.clear() + self._signals = [] + FilterIO.set_selection(widget=self, selection=self._signals) + return + + # Update stored ndim_filter if a new one is provided + if ndim_filter is not None: + self.config.ndim_filter = ndim_filter + + self.clear() + + # Get signals with ndim filtering applied at the FilterIO level + signals = FilterIO.update_with_signal_class( + widget=self, + signal_class_filter=self._signal_class_filter, + client=self.client, + ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO + ) + + # Track signals for validation and FilterIO selection + self._signals = [] + + for device_name, signal_name, signal_config in signals: + # Filter by device if one is set + if self._device and device_name != self._device: + continue + if self._signal_filter: + kind_str = signal_config.get("kind_str") + if kind_str is not None and kind_str not in { + kind.name for kind in self._signal_filter + }: + continue + + # Get storage_name for tooltip + storage_name = signal_config.get("storage_name", "") + + # Store the full signal config as item data if requested + if self._store_signal_config: + self.addItem(signal_name, signal_config) + else: + self.addItem(signal_name) + + # Track for validation + self._signals.append(signal_name) + + # Set tooltip to storage_name (Qt.ToolTipRole = 3) + if storage_name: + self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole) + + # Keep FilterIO selection in sync for validate_signal + FilterIO.set_selection(widget=self, selection=self._signals) + @SafeSlot() def reset_selection(self): """Reset the selection of the combobox.""" @@ -176,22 +368,44 @@ def reset_selection(self): @SafeSlot(str) def on_text_changed(self, text: str): - """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. + """Validate and emit only when the signal is valid. For a positioner, the readback value has to be renamed to the device name. - - Args: - text (str): Text in the combobox. + When using signal_class_filter, device validation is skipped. """ - if self.validate_device(self.device) is False: - return - if self.validate_signal(text) is False: - return - self.device_signal_changed.emit(text) + self.check_validity(text) + + def check_validity(self, input_text: str) -> None: + """Check if the current value is a valid signal and emit only when valid.""" + if self._signal_class_filter: + if self._require_device and (not self._device or not input_text): + is_valid = False + else: + is_valid = self.validate_signal(input_text) + else: + if self._require_device and not self.validate_device(self._device): + is_valid = False + else: + is_valid = self.validate_device(self._device) and self.validate_signal(input_text) + + if is_valid: + self._is_valid_input = True + self.device_signal_changed.emit(input_text) + self.setStyleSheet("border: 1px solid transparent;") + else: + self._is_valid_input = False + self.signal_reset.emit() + if self.isEnabled(): + self.setStyleSheet("border: 1px solid red;") @property def selected_signal_comp_name(self) -> str: return dict(self.signals).get(self.currentText(), {}).get("component_name", "") + @property + def is_valid_input(self) -> bool: + """Whether the current text represents a valid signal selection.""" + return self._is_valid_input + if __name__ == "__main__": # pragma: no cover # pylint: disable=import-outside-toplevel @@ -205,7 +419,14 @@ def selected_signal_comp_name(self) -> str: widget.setFixedSize(200, 200) layout = QVBoxLayout() widget.setLayout(layout) - box = SignalComboBox(device="samx") + box = SignalComboBox( + device="waveform", + signal_class_filter=["AsyncSignal", "AsyncMultiSignal"], + ndim_filter=[1, 2], + store_signal_config=True, + signal_filter=[Kind.hinted, Kind.normal, Kind.config], + ) # change signal filter class to test + box.setEditable(True) layout.addWidget(box) widget.show() app.exec_() diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index ec2426fd7..78329344f 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -210,3 +210,193 @@ def test_signal_combobox_get_signal_name_with_velocity(qtbot, device_signal_comb signal_name = device_signal_combobox.get_signal_name() assert signal_name == "samx_velocity" + + +def test_signal_combobox_get_signal_config(device_signal_combobox): + device_signal_combobox.include_normal_signals = True + device_signal_combobox.include_hinted_signals = True + device_signal_combobox.set_device("samx") + + index = device_signal_combobox.currentIndex() + assert index != -1 + + expected_config = device_signal_combobox.itemData(index) + assert expected_config is not None + assert device_signal_combobox.get_signal_config() == expected_config + + +def test_signal_combobox_get_signal_config_disabled(qtbot, mocked_client): + combobox = create_widget( + qtbot=qtbot, widget=SignalComboBox, client=mocked_client, store_signal_config=False + ) + combobox.include_normal_signals = True + combobox.include_hinted_signals = True + combobox.set_device("samx") + assert combobox.get_signal_config() is None + + +def test_signal_combobox_signal_class_filter_by_device(qtbot, mocked_client): + """Test signal_class_filter restricts signals to the selected device.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ("samx", "samx_readback_async", {"obj_name": "samx_readback_async"}), + ("samy", "samy_readback_async", {"obj_name": "samy_readback_async"}), + ("bpm4i", "bpm4i_value_async", {"obj_name": "bpm4i_value_async"}), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + + assert widget.signals == ["samx_readback_async"] + assert widget.signal_class_filter == ["AsyncSignal"] + + widget.set_device("samy") + assert widget.signals == ["samy_readback_async"] + + +def test_signal_class_filter_setter_clears_to_kind_filters(qtbot, mocked_client): + """Clearing signal_class_filter should rebuild list using Kind filters.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + assert widget.signals == ["samx_readback_async"] + + widget.signal_class_filter = [] + samx = widget.dev.samx + assert widget.signals == [ + ("samx (readback)", samx._info["signals"].get("readback")), + ("setpoint", samx._info["signals"].get("setpoint")), + ("velocity", samx._info["signals"].get("velocity")), + ] + + +def test_signal_class_filter_setter_none_reverts_to_kind_filters(qtbot, mocked_client): + """Setting signal_class_filter to None should revert to Kind-based filtering.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[("samx", "samx_readback_async", {"obj_name": "samx_readback_async"})] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + device="samx", + ) + assert widget.signals == ["samx_readback_async"] + + widget.signal_class_filter = None + samx = widget.dev.samx + assert widget.signals == [ + ("samx (readback)", samx._info["signals"].get("readback")), + ("setpoint", samx._info["signals"].get("setpoint")), + ("velocity", samx._info["signals"].get("velocity")), + ] + + +def test_signal_combobox_set_first_element_as_empty(qtbot, mocked_client): + """set_first_element_as_empty should insert/remove the empty option.""" + widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client) + widget.addItem("item1") + widget.addItem("item2") + + widget.set_first_element_as_empty = True + assert widget.itemText(0) == "" + + widget.set_first_element_as_empty = False + assert widget.itemText(0) == "item1" + + +def test_signal_combobox_class_kind_ndim_filters(qtbot, mocked_client): + """Test class + kind + ndim filters are all applied together.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ( + "samx", + "sig1", + { + "obj_name": "samx_sig1", + "kind_str": "hinted", + "describe": {"signal_info": {"ndim": 1}}, + }, + ), + ( + "samx", + "sig2", + { + "obj_name": "samx_sig2", + "kind_str": "config", + "describe": {"signal_info": {"ndim": 2}}, + }, + ), + ( + "samy", + "sig3", + { + "obj_name": "samy_sig3", + "kind_str": "normal", + "describe": {"signal_info": {"ndim": 1}}, + }, + ), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + ndim_filter=1, + device="samx", + ) + + # Default kinds are hinted + normal, ndim=1, device=samx + assert widget.signals == ["sig1"] + + # Enable config kinds and widen ndim to include sig2 + widget.include_config_signals = True + widget.ndim_filter = 2 + assert widget.signals == ["sig2"] + + +def test_signal_combobox_require_device_validation(qtbot, mocked_client): + """Require device should block validation and list updates without a device.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ( + "samx", + "sig1", + { + "obj_name": "samx_sig1", + "kind_str": "hinted", + "describe": {"signal_info": {"ndim": 1}}, + }, + ) + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=SignalComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + require_device=True, + ) + + assert widget.signals == [] + widget.set_device("samx") + assert widget.signals == ["sig1"] + + resets: list[str] = [] + widget.signal_reset.connect(lambda: resets.append("reset")) + widget.check_validity("") + assert resets == ["reset"] diff --git a/tests/unit_tests/test_filter_io.py b/tests/unit_tests/test_filter_io.py index 818faa7d5..e50871245 100644 --- a/tests/unit_tests/test_filter_io.py +++ b/tests/unit_tests/test_filter_io.py @@ -45,3 +45,30 @@ def test_set_selection_line_edit(line_edit_mock): FilterIO.set_selection(line_edit_mock, selection=["testC"]) assert FilterIO.check_input(widget=line_edit_mock, text="testA") is False assert FilterIO.check_input(widget=line_edit_mock, text="testC") is True + + +def test_update_with_signal_class_combo_box_ndim_filter(dap_mock, mocked_client): + signals = [ + ("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}}), + ("dev1", "sig2", {"describe": {"signal_info": {"ndim": 2}}}), + ] + mocked_client.device_manager.get_bec_signals = lambda _filters: signals + out = FilterIO.update_with_signal_class( + widget=dap_mock.fit_model_combobox, + signal_class_filter=["AsyncSignal"], + client=mocked_client, + ndim_filter=1, + ) + assert out == [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] + + +def test_update_with_signal_class_line_edit_passthrough(line_edit_mock, mocked_client): + signals = [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] + mocked_client.device_manager.get_bec_signals = lambda _filters: signals + out = FilterIO.update_with_signal_class( + widget=line_edit_mock, + signal_class_filter=["AsyncSignal"], + client=mocked_client, + ndim_filter=1, + ) + assert out == signals From 6e398e807740a476dcd8600529a34beb7d4dfe0e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 21 Jan 2026 16:03:49 +0100 Subject: [PATCH 139/155] feat(device_combobox): device filter added based on its signal classes --- .../base_classes/device_input_base.py | 40 +++++++++++- .../device_combobox/device_combobox.py | 61 +++++++++++++++++-- tests/unit_tests/test_device_input_base.py | 22 +++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py index 24b9c7db5..8db1a14a5 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py @@ -32,6 +32,7 @@ class DeviceInputConfig(ConnectionConfig): default: str | None = None arg_name: str | None = None apply_filter: bool = True + signal_class_filter: list[str] = [] @field_validator("device_filter") @classmethod @@ -125,11 +126,13 @@ def update_devices_from_filters(self): current_device = WidgetIO.get_value(widget=self, as_string=True) self.config.device_filter = self.device_filter self.config.readout_filter = self.readout_filter + self.config.signal_class_filter = self.signal_class_filter if self.apply_filter is False: return all_dev = self.dev.enabled_devices + devs = self._filter_devices_by_signal_class(all_dev) # Filter based on device class - devs = [dev for dev in all_dev if self._check_device_filter(dev)] + devs = [dev for dev in devs if self._check_device_filter(dev)] # Filter based on readout priority devs = [dev for dev in devs if self._check_readout_filter(dev)] self.devices = [device.name for device in devs] @@ -190,6 +193,27 @@ def apply_filter(self, value: bool): self.config.apply_filter = value self.update_devices_from_filters() + @SafeProperty("QStringList") + def signal_class_filter(self) -> list[str]: + """ + Get the signal class filter for devices. + + Returns: + list[str]: List of signal class names used for filtering devices. + """ + return self.config.signal_class_filter + + @signal_class_filter.setter + def signal_class_filter(self, value: list[str] | None): + """ + Set the signal class filter and update the device list. + + Args: + value (list[str] | None): List of signal class names to filter by. + """ + self.config.signal_class_filter = value or [] + self.update_devices_from_filters() + @SafeProperty(bool) def filter_to_device(self): """Include devices in filters.""" @@ -379,6 +403,20 @@ def _check_device_filter( """ return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter) + def _filter_devices_by_signal_class( + self, devices: list[Device | BECSignal | ComputedSignal | Positioner] + ) -> list[Device | BECSignal | ComputedSignal | Positioner]: + """Filter devices by signal class, if a signal class filter is set.""" + if not self.config.signal_class_filter: + return devices + if not self.client or not hasattr(self.client, "device_manager"): + return [] + signals = FilterIO.update_with_signal_class( + widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client + ) + allowed_devices = {device_name for device_name, _, _ in signals} + return [dev for dev in devices if dev.name in allowed_devices] + def _check_readout_filter( self, device: Device | BECSignal | ComputedSignal | Positioner ) -> bool: diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index f6bca8d4b..cd07ef6d3 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -27,6 +27,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox): available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied. default: Default device name. arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. + signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown. """ USER_ACCESS = ["set_device", "devices"] @@ -51,6 +52,7 @@ def __init__( available_devices: list[str] | None = None, default: str | None = None, arg_name: str | None = None, + signal_class_filter: list[str] | None = None, **kwargs, ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) @@ -63,6 +65,7 @@ def __init__( self._is_valid_input = False self._accent_colors = get_accent_colors() self._set_first_element_as_empty = False + # We do not consider the config that is passed here, this produced problems # with QtDesigner, since config and input arguments may differ and resolve properly # Implementing this logic and config recoverage is postponed. @@ -85,6 +88,10 @@ def __init__( # Device filter default is None if device_filter is not None: self.set_device_filter(device_filter) + + if signal_class_filter is not None: + self.signal_class_filter = signal_class_filter + # Set default device if passed if default is not None: self.set_device(default) @@ -184,18 +191,62 @@ def validate_device(self, device: str) -> bool: # type: ignore[override] if __name__ == "__main__": # pragma: no cover # pylint: disable=import-outside-toplevel - from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget + from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + ) from bec_widgets.utils.colors import apply_theme app = QApplication([]) apply_theme("dark") widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) + widget.setWindowTitle("DeviceComboBox demo") + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Device filter controls")) + controls = QHBoxLayout() + layout.addLayout(controls) + + class_input = QLineEdit() + class_input.setPlaceholderText("signal_class_filter (comma-separated), e.g. AsyncSignal") + controls.addWidget(class_input) + + filter_device = QCheckBox("Device") + filter_positioner = QCheckBox("Positioner") + filter_signal = QCheckBox("Signal") + filter_computed = QCheckBox("ComputedSignal") + controls.addWidget(filter_device) + controls.addWidget(filter_positioner) + controls.addWidget(filter_signal) + controls.addWidget(filter_computed) + combo = DeviceComboBox() - combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"] + combo.set_first_element_as_empty = True layout.addWidget(combo) + + def _apply_filters(): + raw = class_input.text().strip() + if raw: + combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()] + else: + combo.signal_class_filter = [] + combo.filter_to_device = filter_device.isChecked() + combo.filter_to_positioner = filter_positioner.isChecked() + combo.filter_to_signal = filter_signal.isChecked() + combo.filter_to_computed_signal = filter_computed.isChecked() + + class_input.textChanged.connect(_apply_filters) + filter_device.toggled.connect(_apply_filters) + filter_positioner.toggled.connect(_apply_filters) + filter_signal.toggled.connect(_apply_filters) + filter_computed.toggled.connect(_apply_filters) + _apply_filters() + widget.show() app.exec_() diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 7ab73e946..52fac5302 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -9,6 +9,7 @@ DeviceInputBase, DeviceInputConfig, ) +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from .client_mocks import mocked_client from .conftest import create_widget @@ -142,3 +143,24 @@ def test_device_input_base_properties(device_input_base): ReadoutPriority.MONITORED, ReadoutPriority.ON_REQUEST, ] + + +def test_device_combobox_signal_class_filter(qtbot, mocked_client): + """Test device filtering via signal_class_filter on combobox.""" + mocked_client.device_manager.get_bec_signals = mock.MagicMock( + return_value=[ + ("samx", "async_signal", {"signal_class": "AsyncSignal"}), + ("samy", "async_signal", {"signal_class": "AsyncSignal"}), + ("bpm4i", "async_signal", {"signal_class": "AsyncSignal"}), + ] + ) + widget = create_widget( + qtbot=qtbot, + widget=DeviceComboBox, + client=mocked_client, + signal_class_filter=["AsyncSignal"], + ) + + devices = [widget.itemText(i) for i in range(widget.count())] + assert set(devices) == {"samx", "samy", "bpm4i"} + assert widget.signal_class_filter == ["AsyncSignal"] From 148b41e23843544c2958f774b6173b483790a8ac Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 27 Jan 2026 12:58:34 +0100 Subject: [PATCH 140/155] fix(device_input_widgets): removed RPC access --- bec_widgets/cli/client.py | 145 ------------------ .../device_combobox/device_combobox.py | 4 +- .../device_line_edit/device_line_edit.py | 3 +- .../signal_combobox/signal_combobox.py | 2 +- .../signal_line_edit/signal_line_edit.py | 2 +- .../test_user_interaction_e2e.py | 89 ----------- .../unit_tests/test_client_plugin_widgets.py | 8 +- 7 files changed, 8 insertions(+), 245 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 5a5d359c0..4a98aaa87 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -35,8 +35,6 @@ class _WidgetsEnumType(str, enum.Enum): "DapComboBox": "DapComboBox", "DarkModeButton": "DarkModeButton", "DeviceBrowser": "DeviceBrowser", - "DeviceComboBox": "DeviceComboBox", - "DeviceLineEdit": "DeviceLineEdit", "Heatmap": "Heatmap", "Image": "Image", "LogPanel": "LogPanel", @@ -56,9 +54,7 @@ class _WidgetsEnumType(str, enum.Enum): "ScanControl": "ScanControl", "ScanProgressBar": "ScanProgressBar", "ScatterWaveform": "ScatterWaveform", - "SignalComboBox": "SignalComboBox", "SignalLabel": "SignalLabel", - "SignalLineEdit": "SignalLineEdit", "TextBox": "TextBox", "VSCodeEditor": "VSCodeEditor", "Waveform": "Waveform", @@ -1048,29 +1044,6 @@ def detach(self): """ -class DeviceComboBox(RPCBase): - """Combobox widget for device input with autocomplete for device names.""" - - @rpc_call - def set_device(self, device: "str"): - """ - Set the device. - - Args: - device (str): Default name. - """ - - @property - @rpc_call - def devices(self) -> "list[str]": - """ - Get the list of devices for the applied filters. - - Returns: - list[str]: List of devices. - """ - - class DeviceInitializationProgressBar(RPCBase): """A progress bar that displays the progress of device initialization.""" @@ -1115,39 +1088,6 @@ def detach(self): """ -class DeviceLineEdit(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" - - @rpc_call - def set_device(self, device: "str"): - """ - Set the device. - - Args: - device (str): Default name. - """ - - @property - @rpc_call - def devices(self) -> "list[str]": - """ - Get the list of devices for the applied filters. - - Returns: - list[str]: List of devices. - """ - - @property - @rpc_call - def _is_valid_input(self) -> bool: - """ - Check if the current value is a valid device name. - - Returns: - bool: True if the current value is a valid device name, False otherwise. - """ - - class DockAreaWidget(RPCBase): """Lightweight dock area that exposes the core Qt ADS docking helpers without any""" @@ -5411,49 +5351,6 @@ def z_device_entry(self) -> "str": """ -class SignalComboBox(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" - - @rpc_call - def set_signal(self, signal: str): - """ - Set the signal. - - Args: - signal (str): signal name. - """ - - @rpc_call - def set_device(self, device: "str | None"): - """ - Set the device. When signal_class_filter is active, ensures base-class - logic runs and then refreshes the signal list to show only signals from - that device matching the signal class filter. - - Args: - device(str): device name. - """ - - @property - @rpc_call - def signals(self) -> list[str]: - """ - Get the list of device signals for the applied filters. - - Returns: - list[str]: List of device signals. - """ - - @rpc_call - def get_signal_name(self) -> "str": - """ - Get the signal name from the combobox. - - Returns: - str: The signal name. - """ - - class SignalLabel(RPCBase): @property @rpc_call @@ -5596,48 +5493,6 @@ def max_list_display_len(self) -> "int": """ -class SignalLineEdit(RPCBase): - """Line edit widget for device input with autocomplete for device names.""" - - @property - @rpc_call - def _is_valid_input(self) -> bool: - """ - Check if the current value is a valid device name. - - Returns: - bool: True if the current value is a valid device name, False otherwise. - """ - - @rpc_call - def set_signal(self, signal: str): - """ - Set the signal. - - Args: - signal (str): signal name. - """ - - @rpc_call - def set_device(self, device: str | None): - """ - Set the device. If device is not valid, device will be set to None which happens - - Args: - device(str): device name. - """ - - @property - @rpc_call - def signals(self) -> list[str]: - """ - Get the list of device signals for the applied filters. - - Returns: - list[str]: List of device signals. - """ - - class TextBox(RPCBase): """A widget that displays text in plain and HTML format""" diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index cd07ef6d3..0dcb569d4 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -1,7 +1,6 @@ from bec_lib.callback_handler import EventType from bec_lib.device import ReadoutPriority from qtpy.QtCore import QSize, Signal, Slot -from qtpy.QtGui import QPainter, QPaintEvent, QPen from qtpy.QtWidgets import QComboBox, QSizePolicy from bec_widgets.utils.colors import get_accent_colors @@ -30,10 +29,9 @@ class DeviceComboBox(DeviceInputBase, QComboBox): signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown. """ - USER_ACCESS = ["set_device", "devices"] - ICON_NAME = "list_alt" PLUGIN = True + RPC = False device_selected = Signal(str) device_reset = Signal() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py index e9d523fd8..5917b8062 100644 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +++ b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py @@ -31,12 +31,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit): arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. """ - USER_ACCESS = ["set_device", "devices", "_is_valid_input"] - device_selected = Signal(str) device_config_update = Signal() PLUGIN = True + RPC = False ICON_NAME = "edit_note" def __init__( diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index dcc8b8794..892d30ab7 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -38,7 +38,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): ICON_NAME = "list_alt" PLUGIN = True - RPC = True + RPC = False device_signal_changed = Signal(str) signal_reset = Signal() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py index 759706a1a..a7e9fe1f3 100644 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +++ b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py @@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit): device_signal_changed = Signal(str) PLUGIN = True - RPC = True + RPC = False ICON_NAME = "vital_signs" def __init__( diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index aba9a6d17..8307b948c 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -221,95 +221,6 @@ def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_gene maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceComboBox widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area and widget - widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox) - widget: client.DeviceComboBox - - assert "samx" in widget.devices - assert "bpm4i" in widget.devices - - widget.set_device("samx") - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceLineEdit widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area and widget - widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit) - widget: client.DeviceLineEdit - - assert widget._is_valid_input is False - assert "samx" in widget.devices - assert "bpm4i" in widget.devices - - widget.set_device("samx") - assert widget._is_valid_input is True - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_signal_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceSignalLineEdit widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area and widget - widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit) - widget: client.SignalLineEdit - - widget.set_device("samx") - assert widget._is_valid_input is False - assert widget.signals == [ - "readback", - "setpoint", - "motor_is_moving", - "velocity", - "acceleration", - "tolerance", - ] - widget.set_signal("readback") - assert widget._is_valid_input is True - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the DeviceSignalComboBox widget.""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area and widget - widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) - widget: client.SignalComboBox - - widget.set_device("samx") - info = bec.device_manager.devices.samx._info["signals"] - assert widget.signals == [ - ["samx (readback)", info.get("readback")], - ["setpoint", info.get("setpoint")], - ["motor_is_moving", info.get("motor_is_moving")], - ["velocity", info.get("velocity")], - ["acceleration", info.get("acceleration")], - ["tolerance", info.get("tolerance")], - ] - widget.set_signal("samx (readback)") - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the Image widget.""" diff --git a/tests/unit_tests/test_client_plugin_widgets.py b/tests/unit_tests/test_client_plugin_widgets.py index d438031fa..a863c58c5 100644 --- a/tests/unit_tests/test_client_plugin_widgets.py +++ b/tests/unit_tests/test_client_plugin_widgets.py @@ -34,9 +34,9 @@ class _TestDuplicatePlugin(RPCBase): ... mock_client_module_duplicate = SimpleNamespace() -_TestDuplicatePlugin.__name__ = "DeviceComboBox" +_TestDuplicatePlugin.__name__ = "Waveform" -mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin +mock_client_module_duplicate.Waveform = _TestDuplicatePlugin @patch("bec_lib.logger.bec_logger") @@ -47,14 +47,14 @@ class _TestDuplicatePlugin(RPCBase): ... @patch( "bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets", return_value=BECClassContainer( - [BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")] + [BECClassInfo(name="Waveform", obj=_TestDuplicatePlugin, module="", file="")] ), ) def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock): reload(client) assert ( call( - f"Detected duplicate widget DeviceComboBox in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !" + f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !" ) in bec_logger.logger.warning.mock_calls ) From b79c4862c5ab99f76493f173d80f656d8e2a4486 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 28 Jan 2026 15:06:57 +0100 Subject: [PATCH 141/155] fix(device_combobox): public flag for valid input --- .../control/device_input/device_combobox/device_combobox.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index 0dcb569d4..0f923d0cc 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -186,6 +186,11 @@ def validate_device(self, device: str) -> bool: # type: ignore[override] device = self.itemData(idx)[0] # type: ignore[assignment] return super().validate_device(device) + @property + def is_valid_input(self) -> bool: + """Whether the current text represents a valid device selection.""" + return self._is_valid_input + if __name__ == "__main__": # pragma: no cover # pylint: disable=import-outside-toplevel From 0580b539fa4efbf3c2432ae27374b7078ce64091 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 2 Dec 2025 13:31:03 +0100 Subject: [PATCH 142/155] feat(image): modernization of image widget --- bec_widgets/cli/client.py | 34 +- bec_widgets/widgets/plots/image/image.py | 932 +++++++++++++----- .../toolbar_components/device_selection.py | 255 +++++ docs/user/widgets/image/image_widget.md | 8 +- tests/end-2-end/test_bec_dock_rpc_e2e.py | 2 +- .../end-2-end/test_plotting_framework_e2e.py | 10 +- tests/end-2-end/test_rpc_register_e2e.py | 5 +- .../test_user_interaction_e2e.py | 11 +- tests/unit_tests/test_image_view_next_gen.py | 430 +++++--- 9 files changed, 1287 insertions(+), 400 deletions(-) create mode 100644 bec_widgets/widgets/plots/image/toolbar_components/device_selection.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 4a98aaa87..08b91c9db 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2502,16 +2502,30 @@ def autorange_mode(self) -> "str": @property @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - @monitor.setter + @device_name.setter @rpc_call - def monitor(self) -> "str": + def device_name(self) -> "str": + """ + The name of the device to monitor for image data. + """ + + @property + @rpc_call + def device_entry(self) -> "str": + """ + The signal/entry name to monitor on the device. + """ + + @device_entry.setter + @rpc_call + def device_entry(self) -> "str": """ - The name of the monitor to use for the image. + The signal/entry name to monitor on the device. """ @rpc_call @@ -2617,8 +2631,8 @@ def transpose(self) -> "bool": @rpc_call def image( self, - monitor: "str | tuple | None" = None, - monitor_type: "Literal['auto', '1d', '2d']" = "auto", + device_name: "str | None" = None, + device_entry: "str | None" = None, color_map: "str | None" = None, color_bar: "Literal['simple', 'full'] | None" = None, vrange: "tuple[int, int] | None" = None, @@ -2627,14 +2641,14 @@ def image( Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ @property diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 69968ac40..a8e9b3b16 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -1,27 +1,25 @@ from __future__ import annotations from collections import defaultdict -from typing import Literal, Sequence +from typing import Literal import numpy as np from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import QComboBox, QStyledItemDelegate, QWidget +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QWidget from bec_widgets.utils import ConnectionConfig -from bec_widgets.utils.colors import Colors +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( - BECDeviceFilter, - ReadoutPriority, -) -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_item import ImageItem +from bec_widgets.widgets.plots.image.toolbar_components.device_selection import ( + DeviceSelection, + DeviceSelectionConnection, + device_selection_bundle, +) from bec_widgets.widgets.plots.plot_base import PlotBase logger = bec_logger.logger @@ -44,11 +42,19 @@ class ImageConfig(ConnectionConfig): class ImageLayerConfig(BaseModel): - monitor: str | tuple | None = Field(None, description="The name of the monitor.") - monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.") - source: Literal["device_monitor_1d", "device_monitor_2d", "auto"] = Field( - "auto", description="The source of the image data." + device_name: str = Field("", description="The device name to monitor.") + device_entry: str = Field("", description="The signal/entry name to monitor on the device.") + monitor_type: Literal["1d", "2d"] | None = Field(None, description="The type of monitor.") + source: Literal["device_monitor_1d", "device_monitor_2d"] | None = Field( + None, description="The source of the image data." + ) + async_signal_name: str | None = Field( + None, description="Async signal name (obj_name) used for async endpoints." + ) + connection_status: Literal["connected", "disconnected", "error"] = Field( + "disconnected", description="Current connection status." ) + connection_error: str | None = Field(None, description="Last connection error, if any.") class Image(ImageBase): @@ -74,8 +80,10 @@ class Image(ImageBase): "autorange.setter", "autorange_mode", "autorange_mode.setter", - "monitor", - "monitor.setter", + "device_name", + "device_name.setter", + "device_entry", + "device_entry.setter", "enable_colorbar", "enable_simple_colorbar", "enable_simple_colorbar.setter", @@ -96,6 +104,8 @@ class Image(ImageBase): "rois", ] + SUPPORTED_SIGNALS = ["AsyncSignal", "AsyncMultiSignal", "DynamicSignal"] + def __init__( self, parent: QWidget | None = None, @@ -108,15 +118,27 @@ def __init__( if config is None: config = ImageConfig(widget_class=self.__class__.__name__) self.gui_id = config.gui_id - self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict( - lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto") - ) + self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(ImageLayerConfig) + # Store signal configs separately (not serialized to QSettings) + self._signal_configs: dict[str, dict] = {} + super().__init__( parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs ) + self._device_selection_updating = False + self._autorange_on_next_update = False self._init_toolbar_image() self.layer_removed.connect(self._on_layer_removed) + self.old_scan_id = None self.scan_id = None + self.async_update = False + self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) + + @property + def _config(self) -> ImageLayerConfig: + """Helper property to access the main layer config.""" + return self.subscriptions["main"] ################################## ### Toolbar Initialization @@ -126,46 +148,21 @@ def _init_toolbar_image(self): """ Initializes the toolbar for the image widget. """ - self.device_combo_box = DeviceComboBox( - parent=self, - device_filter=BECDeviceFilter.DEVICE, - readout_priority_filter=[ReadoutPriority.ASYNC], - ) - self.device_combo_box.addItem("", None) - self.device_combo_box.setCurrentText("") - self.device_combo_box.setToolTip("Select Device") - self.device_combo_box.setFixedWidth(150) - self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box)) - - self.dim_combo_box = QComboBox(parent=self) - self.dim_combo_box.addItems(["auto", "1d", "2d"]) - self.dim_combo_box.setCurrentText("auto") - self.dim_combo_box.setToolTip("Monitor Dimension") - self.dim_combo_box.setFixedWidth(100) - self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box)) - - self.toolbar.components.add_safe( - "image_device_combo", WidgetAction(widget=self.device_combo_box, adjust_size=False) + self.toolbar.add_bundle( + device_selection_bundle(self.toolbar.components, client=self.client) ) - self.toolbar.components.add_safe( - "image_dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False) + self.toolbar.connect_bundle( + "device_selection", + DeviceSelectionConnection(self.toolbar.components, target_widget=self), ) - bundle = ToolbarBundle("monitor_selection", self.toolbar.components) - bundle.add_action("image_device_combo") - bundle.add_action("image_dim_combo") - - self.toolbar.add_bundle(bundle) - self.device_combo_box.currentTextChanged.connect(self.connect_monitor) - self.dim_combo_box.currentTextChanged.connect(self.connect_monitor) - crosshair_bundle = self.toolbar.get_bundle("image_crosshair") crosshair_bundle.add_action("image_autorange") crosshair_bundle.add_action("image_colorbar_switch") self.toolbar.show_bundles( [ - "monitor_selection", + "device_selection", "plot_export", "mouse_interaction", "image_crosshair", @@ -178,94 +175,359 @@ def _init_toolbar_image(self): def _adjust_and_connect(self): """ - Adjust the size of the device combo box and populate it with preview signals. + Sync the device selection toolbar with current properties. Has to be done with QTimer.singleShot to ensure the UI is fully initialized, needed for testing. - """ - self._populate_preview_signals() - self._reverse_device_items() - self.device_combo_box.setCurrentText("") # set again default to empty string - def _populate_preview_signals(self) -> None: - """ - Populate the device combo box with preview-signal devices in the - format '_' and store the tuple(device, signal) in - the item's userData for later use. + Note: DeviceComboBox and SignalComboBox auto-populate themselves, no manual population needed. """ - preview_signals = self.client.device_manager.get_bec_signals("PreviewSignal") - for device, signal, signal_config in preview_signals: - label = signal_config.get("obj_name", f"{device}_{signal}") - self.device_combo_box.addItem(label, (device, signal, signal_config)) + self._sync_device_selection() - def _reverse_device_items(self) -> None: + @SafeSlot() + def on_device_selection_changed(self, _): """ - Reverse the current order of items in the device combo box while - keeping their userData and restoring the previous selection. + Called when device or signal selection changes in the toolbar. + This reads from the toolbar and updates the widget properties. """ - current_text = self.device_combo_box.currentText() - items = [ - (self.device_combo_box.itemText(i), self.device_combo_box.itemData(i)) - for i in range(self.device_combo_box.count()) - ] - self.device_combo_box.clear() - for text, data in reversed(items): - self.device_combo_box.addItem(text, data) - if current_text: - self.device_combo_box.setCurrentText(current_text) + if self._device_selection_updating: + return - @SafeSlot() - def connect_monitor(self, *args, **kwargs): - """ - Connect the target widget to the selected monitor based on the current device and dimension. + self._device_selection_updating = True + try: + try: + action = self.toolbar.components.get_action("device_selection") + except Exception: + return - If the selected device is a preview-signal device, it will use the tuple (device, signal) as the monitor. - """ - dim = self.dim_combo_box.currentText() - data = self.device_combo_box.currentData() + if action is None: + return - if isinstance(data, tuple): - self.image(monitor=data, monitor_type="auto") - else: - self.image(monitor=self.device_combo_box.currentText(), monitor_type=dim) + device_selection: DeviceSelection = action.widget + device = device_selection.device_combo_box.currentText() + signal_text = device_selection.signal_combo_box.currentText() + + if not device: + self.device_name = "" + return + if not device_selection.device_combo_box.is_valid_input: + return + + if not device_selection.signal_combo_box.is_valid_input: + if self._config.device_entry: + self.device_entry = "" + if device != self._config.device_name: + self.device_name = device + return + + if device == self._config.device_name and signal_text == self._config.device_entry: + return + + # Get the signal config stored in the combobox + signal_config = device_selection.signal_combo_box.get_signal_config() + + if not signal_config: + # Fallback: try to get config from device + try: + device_obj = self.dev[device] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + logger.warning(f"Could not get signal config for {device}.{signal_text}") + signal_config = None + + # Store signal config and set properties which will trigger the connection + self._signal_configs["main"] = signal_config + self.device_name = device + self.device_entry = signal_text + finally: + self._device_selection_updating = False ################################################################################ # Data Acquisition - @SafeProperty(str) - def monitor(self) -> str: + @SafeProperty(str, auto_emit=True) + def device_name(self) -> str: """ - The name of the monitor to use for the image. + The name of the device to monitor for image data. """ - return self.subscriptions["main"].monitor or "" + return self._config.device_name - @monitor.setter - def monitor(self, value: str): + @device_name.setter + def device_name(self, value: str): """ - Set the monitor for the image. + Set the device name for the image. This should be used together with device_entry. + When both device_name and device_entry are set, the widget connects to that device signal. Args: - value(str): The name of the monitor to set. + value(str): The name of the device to monitor. """ - if self.subscriptions["main"].monitor == value: + if not value: + # Clear the monitor if empty device name + if self._config.device_name: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - try: - self.entry_validator.validate_monitor(value) - except ValueError: + + old_device = self._config.device_name + self._config.device_name = value + + # If we have a device_entry, reconnect with the new device + if self._config.device_entry: + # Try to get fresh signal config for the new device + try: + device_obj = self.dev[value] + # Try to get signal config for the current entry + if self._config.device_entry in device_obj._info.get("signals", {}): + self._signal_configs["main"] = device_obj._info["signals"][ + self._config.device_entry + ] + self._setup_connection() + else: + # Signal doesn't exist on new device + logger.warning( + f"Signal '{self._config.device_entry}' doesn't exist on device '{value}'" + ) + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status( + "error", f"Signal '{self._config.device_entry}' doesn't exist" + ) + except (KeyError, AttributeError): + # Device doesn't exist + logger.warning(f"Device '{value}' not found") + if old_device: + self._disconnect_current_monitor() + self._set_connection_status("error", f"Device '{value}' not found") + + # Toolbar sync happens via SafeProperty auto_emit property_changed handling. + + @SafeProperty(str, auto_emit=True) + def device_entry(self) -> str: + """ + The signal/entry name to monitor on the device. + """ + return self._config.device_entry + + @device_entry.setter + def device_entry(self, value: str): + """ + Set the device entry (signal) for the image. This should be used together with device_name. + When set, it will connect to updates from that device signal. + + Args: + value(str): The signal name to monitor. + """ + if not value: + if self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_entry = "" + self._signal_configs.pop("main", None) + self._set_connection_status("disconnected") return - self.image(monitor=value) + + self._config.device_entry = value + + # If we have a device_name, try to connect + if self._config.device_name: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(value) + if not isinstance(signal_config, dict) or not signal_config.get("signal_class"): + logger.warning( + f"Could not find valid configuration for signal '{value}' " + f"on device '{self._config.device_name}'." + ) + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + return + + self._signal_configs["main"] = signal_config + self._setup_connection() + except (KeyError, AttributeError): + logger.warning( + f"Could not find signal '{value}' on device '{self._config.device_name}'." + ) + # Remove signal config if it can't be fetched + self._signal_configs.pop("main", None) + self._set_connection_status("error", f"Signal '{value}' not found") + + else: + logger.debug(f"device_entry setter: No device set yet for signal '{value}'") @property def main_image(self) -> ImageItem: """Access the main image item.""" return self.layer_manager["main"].image + def _setup_connection(self): + """ + Internal method to setup connection based on current device_name, device_entry, and signal_config. + """ + if not self._config.device_name or not self._config.device_entry: + logger.warning("Cannot setup connection without both device_name and device_entry") + self._set_connection_status("disconnected") + return + + signal_config = self._signal_configs.get("main") + if not signal_config: + logger.warning( + f"Cannot setup connection for {self._config.device_name}.{self._config.device_entry} without signal_config" + ) + self._set_connection_status("error", "Missing signal config") + return + + # Disconnect any existing monitor first + self._disconnect_current_monitor() + + # Determine monitor type and source from signal_config + signal_class = signal_config.get("signal_class", None) + supported_classes = ["PreviewSignal"] + self.SUPPORTED_SIGNALS + + if signal_class not in supported_classes: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' has unsupported signal class '{signal_class}'. " + f"Supported classes: {supported_classes}" + ) + self._set_connection_status("error", f"Unsupported signal class '{signal_class}'") + return + + describe = signal_config.get("describe") or {} + signal_info = describe.get("signal_info") or {} + ndim = signal_info.get("ndim", None) + + if ndim is None: + logger.warning( + f"Signal '{self._config.device_name}.{self._config.device_entry}' does not have a valid 'ndim' in its signal_info." + ) + self._set_connection_status("error", "Missing ndim in signal_info") + return + + config = self.subscriptions["main"] + self.async_update = False + config.async_signal_name = None + + if ndim == 1: + config.source = "device_monitor_1d" + config.monitor_type = "1d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_1d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + elif ndim == 2: + config.source = "device_monitor_2d" + config.monitor_type = "2d" + if signal_class == "PreviewSignal": + self.bec_dispatcher.connect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + elif signal_class in self.SUPPORTED_SIGNALS: + self.async_update = True + config.async_signal_name = signal_config.get( + "obj_name", f"{self._config.device_name}_{self._config.device_entry}" + ) + self._setup_async_image(self.scan_id) + else: + logger.warning( + f"Unsupported ndim '{ndim}' for monitor '{self._config.device_name}.{self._config.device_entry}'." + ) + self._set_connection_status("error", f"Unsupported ndim '{ndim}'") + return + + self._set_connection_status("connected") + logger.info( + f"Connected to {self._config.device_name}.{self._config.device_entry} with type {config.monitor_type}" + ) + self._autorange_on_next_update = True + + def _disconnect_current_monitor(self): + """ + Internal method to disconnect the current monitor subscriptions. + """ + if not self._config.device_name or not self._config.device_entry: + return + + config = self.subscriptions["main"] + + if self.async_update: + async_signal_name = config.async_signal_name or self._config.device_entry + ids_to_check = [self.scan_id, self.old_scan_id] + + if config.source == "device_monitor_1d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_async_signal( + scan_id, self._config.device_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}" + ) + elif config.source == "device_monitor_2d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_async_signal( + scan_id, self._config.device_name, async_signal_name + ), + ) + logger.info( + f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}" + ) + + else: + if config.source == "device_monitor_1d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 1d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + elif config.source == "device_monitor_2d": + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_preview( + self._config.device_name, self._config.device_entry + ), + ) + logger.info( + f"Disconnecting preview 2d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}" + ) + + # Reset async state + self.async_update = False + config.async_signal_name = None + self._set_connection_status("disconnected") + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot(popup_error=True) def image( self, - monitor: str | tuple | None = None, - monitor_type: Literal["auto", "1d", "2d"] = "auto", + device_name: str | None = None, + device_entry: str | None = None, color_map: str | None = None, color_bar: Literal["simple", "full"] | None = None, vrange: tuple[int, int] | None = None, @@ -274,30 +536,39 @@ def image( Set the image source and update the image. Args: - monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected. - monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected. + device_entry(str|None): The signal/entry name to monitor on the device. color_map(str): The color map to use for the image. color_bar(str): The type of color bar to use. Options are "simple" or "full". vrange(tuple): The range of values to use for the color map. Returns: - ImageItem: The image object. + ImageItem: The image object, or None if connection failed. """ + # Disconnect existing monitor if any + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() - if self.subscriptions["main"].monitor: - self.disconnect_monitor(self.subscriptions["main"].monitor) - if monitor is None or monitor == "": - logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed") + if not device_name or not device_entry: + if device_name or device_entry: + logger.warning("Both device_name and device_entry must be specified") + else: + logger.info("Disconnecting image monitor") + self.device_name = "" return None - if isinstance(monitor, str): - self.entry_validator.validate_monitor(monitor) - elif isinstance(monitor, Sequence): - self.entry_validator.validate_monitor(monitor[0]) - else: - raise ValueError(f"Invalid monitor type: {type(monitor)}") + # Validate device + self.entry_validator.validate_monitor(device_name) + + # Clear old entry first to avoid reconnect attempts on the new device + if self._config.device_entry: + self.device_entry = "" - self.set_image_update(monitor=monitor, type=monitor_type) + # Set properties to trigger connection + self.device_name = device_name + self.device_entry = device_entry + + # Apply visual settings if color_map is not None: self.main_image.color_map = color_map if color_bar is not None: @@ -305,32 +576,85 @@ def image( if vrange is not None: self.vrange = vrange - self._sync_device_selection() - return self.main_image def _sync_device_selection(self): """ - Synchronize the device selection with the current monitor. + Synchronize the device and signal comboboxes with the current monitor state. + This ensures the toolbar reflects the device_name and device_entry properties. """ - config = self.subscriptions["main"] - if config.monitor is not None: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - if isinstance(config.monitor, (list, tuple)): - self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}") - else: - self.device_combo_box.setCurrentText(config.monitor) - self.dim_combo_box.setCurrentText(config.monitor_type) - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) - else: - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(True) - self.device_combo_box.setCurrentText("") - self.dim_combo_box.setCurrentText("auto") - for combo in (self.device_combo_box, self.dim_combo_box): - combo.blockSignals(False) + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"Image ({self.object_name}) toolbar was not ready during init.") + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + target_device = self._config.device_name or "" + target_entry = self._config.device_entry or "" + + # Check if already synced + if ( + device_selection.device_combo_box.currentText() == target_device + and device_selection.signal_combo_box.currentText() == target_entry + ): + return + + device_selection.set_device_and_signal(target_device, target_entry) + + def _sync_device_entry_from_toolbar(self) -> None: + """ + Pull the signal selection from the toolbar if it differs from the current device_entry. + This keeps CLI-driven device_name updates in sync with the signal combobox state. + """ + if self._device_selection_updating: + return + + if not self._config.device_name: + return + + try: + device_selection_action = self.toolbar.components.get_action("device_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + return + + if device_selection_action is None: + return + + device_selection: DeviceSelection = device_selection_action.widget + if device_selection.device_combo_box.currentText() != self._config.device_name: + return + + signal_text = device_selection.signal_combo_box.currentText() + if not signal_text or signal_text == self._config.device_entry: + return + + signal_config = device_selection.signal_combo_box.get_signal_config() + if not signal_config: + try: + device_obj = self.dev[self._config.device_name] + signal_config = device_obj._info["signals"].get(signal_text, {}) + except (KeyError, AttributeError): + signal_config = None + + if not signal_config: + return + + self._signal_configs["main"] = signal_config + self._device_selection_updating = True + try: + self.device_entry = signal_text + finally: + self._device_selection_updating = False + + def _set_connection_status(self, status: str, message: str | None = None) -> None: + self._config.connection_status = status + self._config.connection_error = message + self.property_changed.emit("connection_status", status) + self.property_changed.emit("connection_error", message or "") ################################################################################ # Post Processing @@ -411,107 +735,183 @@ def transpose(self, enable: bool): ######################################## # Connections - @SafeSlot() - def set_image_update(self, monitor: str | tuple, type: Literal["1d", "2d", "auto"]): + @SafeSlot(dict, dict) + def on_scan_status(self, msg: dict, meta: dict): """ - Set the image update method for the given monitor. + Initial scan status message handler, which is triggered at the beginning and end of scan. + Needed for setup of AsyncSignal connections. Args: - monitor(str): The name of the monitor to use for the image. - type(str): The type of monitor to use. Options are "1d", "2d", or "auto". + msg(dict): The message content. + meta(dict): The message metadata. """ + current_scan_id = msg.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - # TODO consider moving connecting and disconnecting logic to Image itself if multiple images - if isinstance(monitor, (list, tuple)): - device = self.dev[monitor[0]] - signal = monitor[1] - if len(monitor) == 3: - signal_config = monitor[2] - else: - signal_config = device._info["signals"][signal] - signal_class = signal_config.get("signal_class", None) - if signal_class != "PreviewSignal": - logger.warning(f"Signal '{monitor}' is not a PreviewSignal.") - return + @SafeSlot(dict, dict) + def on_scan_progress(self, msg: dict, meta: dict): + """ + For setting async image readback during scan progress updates if widget is started later than scan. - ndim = signal_config.get("describe", None).get("signal_info", None).get("ndim", None) - if ndim is None: - logger.warning( - f"Signal '{monitor}' does not have a valid 'ndim' in its signal_info." - ) - return + Args: + msg(dict): The message content. + meta(dict): The message metadata. + """ + current_scan_id = meta.get("scan_id", None) + if current_scan_id is None: + return + self._handle_scan_change(current_scan_id) - if ndim == 1: - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif ndim == 2: - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(device.name, signal) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" + def _handle_scan_change(self, current_scan_id: str): + """ + Update internal scan ids and refresh async connections if needed. + Also clears image buffers when scan changes. - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - if type == "1d": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_1d" - self.subscriptions["main"].monitor_type = "1d" - elif type == "2d": - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "device_monitor_2d" - self.subscriptions["main"].monitor_type = "2d" - elif type == "auto": - self.bec_dispatcher.connect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.connect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].source = "auto" - logger.warning( - f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints." - ) - self.subscriptions["main"].monitor_type = "auto" + Args: + current_scan_id (str): The current scan identifier. + """ + if current_scan_id == self.scan_id: + return + + # Scan ID changed - clear buffers and reset image + self.old_scan_id = self.scan_id + self.scan_id = current_scan_id - logger.info(f"Connected to {monitor} with type {type}") - self.subscriptions["main"].monitor = monitor + # Clear image buffer for 1D data accumulation + self.main_image.clear() + if hasattr(self.main_image, "buffer"): + self.main_image.buffer = [] + self.main_image.max_len = 0 + + # Reset crosshair if present + if self.crosshair is not None: + self.crosshair.reset() + + # Reconnect async image subscription with new scan_id + if self.async_update: + self._setup_async_image(scan_id=self.scan_id) - def disconnect_monitor(self, monitor: str | tuple): + def _get_async_signal_name(self) -> tuple[str, str] | None: + """ + Returns device name and async signal name used for endpoints/messages. + + Returns: + tuple[str, str] | None: (device_name, async_signal_name) or None if not available. + """ + if not self._config.device_name or not self._config.device_entry: + return None + + config = self.subscriptions["main"] + async_signal = config.async_signal_name or self._config.device_entry + return self._config.device_name, async_signal + + def _setup_async_image(self, scan_id: str | None): + """ + (Re)connect async image readback for the current scan. + + Args: + scan_id (str | None): The scan identifier to subscribe to. + """ + if not self.async_update: + return + + config = self.subscriptions["main"] + async_names = self._get_async_signal_name() + if async_names is None: + logger.info("Async image setup skipped because monitor information is incomplete.") + return + + device_name, async_signal = async_names + if config.monitor_type == "1d": + slot = self.on_image_update_1d + elif config.monitor_type == "2d": + slot = self.on_image_update_2d + else: + logger.warning( + f"Async image setup skipped due to unsupported monitor type '{config.monitor_type}'." + ) + return + + # Disconnect any previous scan subscriptions to avoid stale updates. + for prev_scan_id in (self.old_scan_id, self.scan_id): + if prev_scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + slot, MessageEndpoints.device_async_signal(prev_scan_id, device_name, async_signal) + ) + + if scan_id is None: + logger.info("Scan ID not available yet; delaying async image subscription.") + return + + self.bec_dispatcher.connect_slot( + slot, + MessageEndpoints.device_async_signal(scan_id, device_name, async_signal), + from_start=True, + cb_info={"scan_id": scan_id}, + ) + logger.info(f"Setup async image for {device_name}.{async_signal} and scan {scan_id}.") + + def disconnect_monitor(self, device_name: str | None = None, device_entry: str | None = None): """ Disconnect the monitor from the image update signals, both 1D and 2D. Args: - monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals. + device_name(str|None): The name of the device to disconnect. Defaults to current device. + device_entry(str|None): The signal/entry name to disconnect. Defaults to current entry. """ - if isinstance(monitor, (list, tuple)): - if self.subscriptions["main"].source == "device_monitor_1d": + config = self.subscriptions["main"] + target_device = device_name or self._config.device_name + target_entry = device_entry or self._config.device_entry + + if not target_device or not target_entry: + logger.warning("Cannot disconnect monitor without both device_name and device_entry") + return + + if self.async_update: + async_signal_name = config.async_signal_name or target_entry + ids_to_check = [self.scan_id, self.old_scan_id] + if config.source == "device_monitor_1d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_1d, + MessageEndpoints.device_async_signal( + scan_id, target_device, async_signal_name + ), + ) + elif config.source == "device_monitor_2d": + for scan_id in ids_to_check: + if scan_id is None: + continue + self.bec_dispatcher.disconnect_slot( + self.on_image_update_2d, + MessageEndpoints.device_async_signal( + scan_id, target_device, async_signal_name + ), + ) + else: + if config.source == "device_monitor_1d": self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1]) + self.on_image_update_1d, + MessageEndpoints.device_preview(target_device, target_entry), ) - elif self.subscriptions["main"].source == "device_monitor_2d": + elif config.source == "device_monitor_2d": self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_preview(monitor[0], monitor[1]) + self.on_image_update_2d, + MessageEndpoints.device_preview(target_device, target_entry), ) else: logger.warning( - f"Cannot disconnect monitor {monitor} with source {self.subscriptions['main'].source}" + f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}" ) return - else: # FIXME old monitor 1d/2d endpoint handling, present for backwards compatibility, will be removed in future versions - self.bec_dispatcher.disconnect_slot( - self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor) - ) - self.bec_dispatcher.disconnect_slot( - self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor) - ) - self.subscriptions["main"].monitor = None + + self.subscriptions["main"].async_signal_name = None + self.async_update = False self._sync_device_selection() ######################################## @@ -521,32 +921,37 @@ def disconnect_monitor(self, monitor: str | tuple): def on_image_update_1d(self, msg: dict, metadata: dict): """ Update the image with 1D data. + For preview signals: metadata doesn't contain scan_id. + For async signals: scan_id is managed via on_scan_status/on_scan_progress. Args: msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] - current_scan_id = metadata.get("scan_id", None) + try: + image = self.main_image + except Exception: + return + data = self._get_payload_data(msg) - if current_scan_id is None: + if data is None: + logger.warning("No data received for image update from 1D.") return - if current_scan_id != self.scan_id: - self.scan_id = current_scan_id - self.main_image.clear() - self.main_image.buffer = [] - self.main_image.max_len = 0 - if self.crosshair is not None: - self.crosshair.reset() - image_buffer = self.adjust_image_buffer(self.main_image, data) + + image_buffer = self.adjust_image_buffer(image, data) + if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(image_buffer) + image.set_data(image_buffer) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() - def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray: + @staticmethod + def adjust_image_buffer(image: ImageItem, new_data: np.ndarray) -> np.ndarray: """ Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length. @@ -582,6 +987,7 @@ def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndar ######################################## # 2D updates + @SafeSlot(dict, dict) def on_image_update_2d(self, msg: dict, metadata: dict): """ Update the image with 2D data. @@ -590,14 +996,40 @@ def on_image_update_2d(self, msg: dict, metadata: dict): msg(dict): The message containing the data. metadata(dict): The metadata associated with the message. """ - data = msg["data"] + try: + image = self.main_image + except Exception: + return + data = self._get_payload_data(msg) + if data is None: + logger.warning("No data received for image update from 2D.") + return if self._color_bar is not None: self._color_bar.blockSignals(True) - self.main_image.set_data(data) + image.set_data(data) if self._color_bar is not None: self._color_bar.blockSignals(False) + if self._autorange_on_next_update: + self._autorange_on_next_update = False + self.auto_range() self.image_updated.emit() + def _get_payload_data(self, msg: dict) -> np.ndarray | None: + """ + Extract payload from async/preview/monitor1D/2D message structures due to inconsistent formats in backend. + + Args: + msg (dict): The incoming message containing data. + """ + if not self.async_update: + return msg.get("data") + async_names = self._get_async_signal_name() + if async_names is None: + logger.warning("Async payload extraction failed; monitor info incomplete.") + return None + _, async_signal = async_names + return msg.get("signals", {}).get(async_signal, {}).get("value", None) + ################################################################################ # Clean up ################################################################################ @@ -612,28 +1044,33 @@ def _on_layer_removed(self, layer_name: str): """ if layer_name not in self.subscriptions: return - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - config.monitor = None + # For the main layer, disconnect current monitor + if layer_name == "main" and self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self._config.device_name = "" + self._config.device_entry = "" + self._signal_configs.pop("main", None) def cleanup(self): """ Disconnect the image update signals and clean up the image. """ self.layer_removed.disconnect(self._on_layer_removed) - for layer_name in list(self.subscriptions.keys()): - config = self.subscriptions[layer_name] - if config.monitor is not None: - self.disconnect_monitor(config.monitor) - del self.subscriptions[layer_name] + + # Disconnect current monitor + if self._config.device_name and self._config.device_entry: + self._disconnect_current_monitor() + self.subscriptions.clear() - # Toolbar cleanup - self.device_combo_box.close() - self.device_combo_box.deleteLater() - self.dim_combo_box.close() - self.dim_combo_box.deleteLater() + # Toolbar cleanup - disconnect the device_selection bundle + try: + self.toolbar.disconnect_bundle("device_selection") + except Exception: # noqa: BLE001 + pass + + self.bec_dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status()) + self.bec_dispatcher.disconnect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) super().cleanup() @@ -643,6 +1080,7 @@ def cleanup(self): from qtpy.QtWidgets import QApplication, QHBoxLayout app = QApplication(sys.argv) + apply_theme("dark") win = QWidget() win.setWindowTitle("Image Demo") ml = QHBoxLayout(win) diff --git a/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py new file mode 100644 index 000000000..caef8657a --- /dev/null +++ b/bec_widgets/widgets/plots/image/toolbar_components/device_selection.py @@ -0,0 +1,255 @@ +from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget + +from bec_widgets.utils.toolbars.actions import WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox + + +class DeviceSelection(QWidget): + """Device and signal selection widget for image toolbar.""" + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent) + + self.client = client + self.supported_signals = [ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ] + + # Create device combobox with signal class filter + # This will only show devices that have signals matching the supported signal classes + self.device_combo_box = DeviceComboBox( + parent=self, client=self.client, signal_class_filter=self.supported_signals + ) + self.device_combo_box.setToolTip("Select Device") + self.device_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.device_combo_box.lineEdit().setPlaceholderText("Select Device") + + # Configure SignalComboBox to filter by PreviewSignal and supported async signals + # Also filter by ndim (1D and 2D only) for Image widget + self.signal_combo_box = SignalComboBox( + parent=self, + client=self.client, + signal_class_filter=[ + "PreviewSignal", + "AsyncSignal", + "AsyncMultiSignal", + "DynamicSignal", + ], + ndim_filter=[1, 2], # Only show 1D and 2D signals for Image widget + store_signal_config=True, + require_device=True, + ) + self.signal_combo_box.setToolTip("Select Signal") + self.signal_combo_box.setEditable(True) + # Set expanding size policy so it grows with available space + self.signal_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.signal_combo_box.lineEdit().setPlaceholderText("Select Signal") + + # Connect comboboxes together + self.device_combo_box.currentTextChanged.connect(self.signal_combo_box.set_device) + self.device_combo_box.device_reset.connect(self.signal_combo_box.reset_selection) + + # Simple horizontal layout with stretch to fill space + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.device_combo_box, stretch=1) + layout.addWidget(self.signal_combo_box, stretch=1) + + def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None: + """Set the displayed device and signal without emitting selection signals.""" + device_name = device_name or "" + device_entry = device_entry or "" + + self.device_combo_box.blockSignals(True) + self.signal_combo_box.blockSignals(True) + + try: + if device_name: + # Set device in device_combo_box + index = self.device_combo_box.findText(device_name) + if index >= 0: + self.device_combo_box.setCurrentIndex(index) + else: + # Device not found in list, but still set it + self.device_combo_box.setCurrentText(device_name) + + # Only update signal combobox device filter if it's actually changing + # This prevents redundant repopulation which can cause duplicates !!!! + current_device = getattr(self.signal_combo_box, "_device", None) + if current_device != device_name: + self.signal_combo_box.set_device(device_name) + + # Sync signal combobox selection + if device_entry: + # Try to find the signal by component_name (which is what's displayed) + found = False + for i in range(self.signal_combo_box.count()): + text = self.signal_combo_box.itemText(i) + config_data = self.signal_combo_box.itemData(i) + + # Check if this matches our signal + if config_data: + component_name = config_data.get("component_name", "") + if text == component_name or text == device_entry: + self.signal_combo_box.setCurrentIndex(i) + found = True + break + + if not found: + # Fallback: try to match the device_entry directly + index = self.signal_combo_box.findText(device_entry) + if index >= 0: + self.signal_combo_box.setCurrentIndex(index) + else: + # No device set, clear selections + self.device_combo_box.setCurrentText("") + self.signal_combo_box.reset_selection() + finally: + # Always unblock signals + self.device_combo_box.blockSignals(False) + self.signal_combo_box.blockSignals(False) + + def set_connection_status(self, status: str, message: str | None = None) -> None: + tooltip = f"Connection status: {status}" + if message: + tooltip = f"{tooltip}\n{message}" + self.device_combo_box.setToolTip(tooltip) + self.signal_combo_box.setToolTip(tooltip) + + if not self.device_combo_box.is_valid_input or not self.signal_combo_box.is_valid_input: + return + + if status == "error": + style = "border: 1px solid orange;" + else: + style = "border: 1px solid transparent;" + + self.device_combo_box.setStyleSheet(style) + self.signal_combo_box.setStyleSheet(style) + + def cleanup(self): + """Clean up the widget resources.""" + self.device_combo_box.close() + self.device_combo_box.deleteLater() + self.signal_combo_box.close() + self.signal_combo_box.deleteLater() + + +def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle: + """ + Creates a device selection toolbar bundle for Image widget. + + Includes a resizable splitter after the device selection. All subsequent bundles' + actions will appear compactly after the splitter with no gaps. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + client: The BEC client instance. + + Returns: + ToolbarBundle: The device selection toolbar bundle. + """ + device_selection_widget = DeviceSelection(parent=components.toolbar, client=client) + components.add_safe( + "device_selection", WidgetAction(widget=device_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("device_selection", components) + bundle.add_action("device_selection") + + bundle.add_splitter( + name="device_selection_splitter", + target_widget=device_selection_widget, + min_width=210, + max_width=600, + ) + + return bundle + + +class DeviceSelectionConnection(BundleConnection): + """ + Connection helper for the device selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "device_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + self.register_property_sync("device_name", self._sync_from_device_name) + self.register_property_sync("device_entry", self._sync_from_device_entry) + self.register_property_sync("connection_status", self._sync_connection_status) + self.register_property_sync("connection_error", self._sync_connection_status) + + def _widget(self) -> DeviceSelection: + return self.components.get_action("device_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.connect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.connect( + self.target_widget.on_device_selection_changed + ) + self.connect_property_sync(self.target_widget) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.device_combo_box.device_selected.disconnect( + self.target_widget.on_device_selection_changed + ) + widget.signal_combo_box.device_signal_changed.disconnect( + self.target_widget.on_device_selection_changed + ) + self.disconnect_property_sync(self.target_widget) + self._connected = False + widget.cleanup() + + def _sync_from_device_name(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + self.target_widget._sync_device_entry_from_toolbar() + + def _sync_from_device_entry(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_device_and_signal( + self.target_widget.device_name, self.target_widget.device_entry + ) + + def _sync_connection_status(self, _): + try: + widget = self._widget() + except Exception: + return + + widget.set_connection_status( + self.target_widget._config.connection_status, + self.target_widget._config.connection_error, + ) diff --git a/docs/user/widgets/image/image_widget.md b/docs/user/widgets/image/image_widget.md index c5dc3b243..cc02dbd6d 100644 --- a/docs/user/widgets/image/image_widget.md +++ b/docs/user/widgets/image/image_widget.md @@ -32,7 +32,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='eiger', monitor_type='2d') +img_widget.image(device_name='eiger', device_entry='preview') img_widget.title = "Camera Image - Eiger Detector" ``` @@ -46,7 +46,7 @@ dock_area = gui.new() img_widget = dock_area.new().new(gui.available_widgets.Image) # Add an ImageWidget to the BECFigure for a 2D detector -img_widget.image(monitor='waveform', monitor_type='1d') +img_widget.image(device_name='waveform', device_entry='data') img_widget.title = "Line Detector Data" # Optional: Set the color map and value range @@ -84,7 +84,7 @@ The Image Widget can be configured for different detectors by specifying the cor ```python # For a 2D camera detector -img_widget = fig.image(monitor='eiger', monitor_type='2d') +img_widget = fig.image(device_name='eiger', device_entry='preview') img_widget.set_title("Eiger Camera Image") ``` @@ -92,7 +92,7 @@ img_widget.set_title("Eiger Camera Image") ```python # For a 1D line detector -img_widget = fig.image(monitor='waveform', monitor_type='1d') +img_widget = fig.image(device_name='waveform', device_entry='data') img_widget.set_title("Line Detector Data") ``` diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index 180d4806c..81a9853d5 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -59,7 +59,7 @@ def check_widgets_registered(): mm.map("samx", "samy") curve = wf.plot(x_name="samx", y_name="bpm4i") - im_item = im.image("eiger") + im_item = im.image(device_name="eiger", device_entry="preview") assert curve.__class__.__name__ == "RPCReference" assert curve.__class__ == RPCReference diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index eddbbf29f..e98c9236c 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -42,7 +42,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): c3 = wf.plot(y=[1, 2, 3], x=[1, 2, 3]) assert c3.object_name == "Curve_0" - im.image(monitor="eiger") + im.image(device_name="eiger", device_entry="preview") mm.map(x_name="samx", y_name="samy") sw.plot(x_name="samx", y_name="samy", z_name="bpm4a") mw.plot(monitor="waveform") @@ -165,14 +165,14 @@ def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj): scans = client.scans im = dock_area.new("Image") - im.image(monitor="eiger") + im.image(device_name="eiger", device_entry="preview") status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False) status.wait() - last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[ - "data" - ].data + last_image_device = client.connector.get_last( + MessageEndpoints.device_preview("eiger", "preview") + )["data"].data last_image_plot = im.main_image.get_data() # check plotted data diff --git a/tests/end-2-end/test_rpc_register_e2e.py b/tests/end-2-end/test_rpc_register_e2e.py index 5eb521982..3e1bfca3d 100644 --- a/tests/end-2-end/test_rpc_register_e2e.py +++ b/tests/end-2-end/test_rpc_register_e2e.py @@ -15,7 +15,7 @@ def test_rpc_reference_objects(connected_client_gui_obj): plt.plot(x_name="samx", y_name="bpm4i") im = dock_area.new("Image") - im.image("eiger") + im.image(device_name="eiger", device_entry="preview") motor_map = dock_area.new("MotorMap") motor_map.map("samx", "samy") plt_z = dock_area.new("Waveform") @@ -23,7 +23,8 @@ def test_rpc_reference_objects(connected_client_gui_obj): assert len(plt_z.curves) == 1 assert len(plt.curves) == 1 - assert im.monitor == "eiger" + assert im.device_name == "eiger" + assert im.device_entry == "preview" assert isinstance(im.main_image, RPCReference) image_item = gui._ipython_registry.get(im.main_image._gui_id, None) diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 8307b948c..cb5d85b57 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -16,6 +16,7 @@ import numpy as np import pytest +from bec_lib.endpoints import MessageEndpoints from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference @@ -233,7 +234,7 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro scans = bec.scans dev = bec.device_manager.devices # Test rpc calls - img = widget.image(dev.eiger) + img = widget.image(device_name=dev.eiger.name, device_entry="preview") assert img.get_data() is None # Run a scan and plot the image s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) @@ -247,13 +248,13 @@ def _wait_for_scan_in_history(): qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000) # Check that last image is equivalent to data in Redis - last_img = bec.device_monitor.get_data( - dev.eiger, count=1 - ) # Get last image from Redis monitor 2D endpoint + last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[ + "data" + ].data assert np.allclose(img.get_data(), last_img) # Now add a device with a preview signal - img = widget.image(["eiger", "preview"]) + img = widget.image(device_name="eiger", device_entry="preview") s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) s.wait() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 480502515..6144468d8 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -1,6 +1,7 @@ import numpy as np import pyqtgraph as pg import pytest +from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QPointF from bec_widgets.widgets.plots.image.image import Image @@ -12,6 +13,23 @@ ################################################## +def _set_signal_config( + client, + device_name: str, + signal_name: str, + signal_class: str, + ndim: int, + obj_name: str | None = None, +): + device = client.device_manager.devices[device_name] + device._info["signals"][signal_name] = { + "obj_name": obj_name or signal_name, + "signal_class": signal_class, + "component_name": signal_name, + "describe": {"signal_info": {"ndim": ndim}}, + } + + def test_initialization_defaults(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) assert bec_image_view.color_map == "plasma" @@ -114,32 +132,35 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type): ############################################## -# Preview‑signal update mechanism +# Device/signal update mechanism -def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_1d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 1‑D PreviewSignal connects using the 1‑D path and updates correctly. + Ensure that calling .image() with a 1‑D PreviewSignal connects using the 1‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "waveform1d_img", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 1}}, - } + _set_signal_config( + mocked_client, + "waveform1d", + "img", + signal_class="PreviewSignal", + ndim=1, + obj_name="waveform1d_img", + ) - # Set the image monitor to the preview signal - view.image(monitor=("waveform1d", "img", signal_config)) + view.image(device_name="waveform1d", device_entry="img") # Subscriptions should indicate 1‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_1d" assert sub.monitor_type == "1d" - assert sub.monitor == ("waveform1d", "img", signal_config) + assert view.device_name == "waveform1d" + assert view.device_entry == "img" # Simulate a waveform update from the dispatcher waveform = np.arange(25, dtype=float) @@ -148,29 +169,32 @@ def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.raw_data[0], waveform) -def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): +def test_image_setup_preview_signal_2d(qtbot, mocked_client): """ - Ensure that calling .image() with a (device, signal, config) tuple representing - a 2‑D PreviewSignal connects using the 2‑D path and updates correctly. + Ensure that calling .image() with a 2‑D PreviewSignal connects using the 2‑D path + and updates correctly. """ import numpy as np view = create_widget(qtbot, Image, client=mocked_client) - signal_config = { - "obj_name": "eiger_img2d", - "signal_class": "PreviewSignal", - "describe": {"signal_info": {"ndim": 2}}, - } + _set_signal_config( + mocked_client, + "eiger", + "img2d", + signal_class="PreviewSignal", + ndim=2, + obj_name="eiger_img2d", + ) - # Set the image monitor to the preview signal - view.image(monitor=("eiger", "img2d", signal_config)) + view.image(device_name="eiger", device_entry="img2d") # Subscriptions should indicate 2‑D preview connection sub = view.subscriptions["main"] assert sub.source == "device_monitor_2d" assert sub.monitor_type == "2d" - assert sub.monitor == ("eiger", "img2d", signal_config) + assert view.device_name == "eiger" + assert view.device_entry == "img2d" # Simulate a 2‑D image update test_data = np.arange(16, dtype=float).reshape(4, 4) @@ -178,38 +202,197 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch): np.testing.assert_array_equal(view.main_image.image, test_data) +def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch): + """ + Preview/async combobox should omit 0‑D signals. + """ + view = create_widget(qtbot, Image, client=mocked_client) + + def fake_get(signal_class_filter): + signal_classes = ( + signal_class_filter + if isinstance(signal_class_filter, (list, tuple, set)) + else [signal_class_filter] + ) + if "PreviewSignal" in signal_classes: + return [ + ( + "eiger", + "sig0d", + { + "obj_name": "sig0d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 0}}, + }, + ), + ( + "eiger", + "sig2d", + { + "obj_name": "sig2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ), + ] + return [] + + monkeypatch.setattr(view.client.device_manager, "get_bec_signals", fake_get) + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.set_device("eiger") + device_selection.signal_combo_box.update_signals_from_signal_classes() + + texts = [ + device_selection.signal_combo_box.itemText(i) + for i in range(device_selection.signal_combo_box.count()) + ] + assert "sig0d" not in texts + assert "sig2d" in texts + + +def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch): + """ + Verify async signals use obj_name for endpoints/payloads and reconnect with scan_id. + """ + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + assert view.subscriptions["main"].async_signal_name == "async_obj" + assert view.async_update is True + + # Prepare scan ids and capture dispatcher calls + view.old_scan_id = "old_scan" + view.scan_id = "new_scan" + connected = [] + disconnected = [] + monkeypatch.setattr( + view.bec_dispatcher, + "connect_slot", + lambda slot, endpoint, from_start=False, cb_info=None: connected.append( + (slot, endpoint, from_start, cb_info) + ), + ) + monkeypatch.setattr( + view.bec_dispatcher, + "disconnect_slot", + lambda slot, endpoint: disconnected.append((slot, endpoint)), + ) + + view._setup_async_image(view.scan_id) + + expected_new = MessageEndpoints.device_async_signal("new_scan", "eiger", "async_obj") + expected_old = MessageEndpoints.device_async_signal("old_scan", "eiger", "async_obj") + assert any(ep == expected_new for _, ep, _, _ in connected) + assert any(ep == expected_old for _, ep in disconnected) + + # Payload extraction should use obj_name + payload = np.array([1, 2, 3]) + msg = {"signals": {"async_obj": {"value": payload}}} + assert np.array_equal(view._get_payload_data(msg), payload) + + +def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config( + mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj" + ) + + view.image(device_name="eiger", device_entry="img") + view.scan_id = "scan_x" + view.old_scan_id = "scan_y" + view.subscriptions["main"].async_signal_name = "async_obj" + + # Avoid touching real dispatcher + monkeypatch.setattr(view.bec_dispatcher, "disconnect_slot", lambda *args, **kwargs: None) + + view.disconnect_monitor(device_name="eiger", device_entry="img") + + assert view.subscriptions["main"].async_signal_name is None + assert view.async_update is False + + ############################################## -# Device monitor endpoint update mechanism +# Connection guardrails -def test_image_setup_image_2d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="2d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" - assert bec_image_view.subscriptions["main"].monitor_type == "2d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="Signal", ndim=2) + view.image(device_name="eiger", device_entry="img") -def test_image_setup_image_1d(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="1d") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "device_monitor_1d" - assert bec_image_view.subscriptions["main"].monitor_type == "1d" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None + assert view.subscriptions["main"].source is None + assert view.subscriptions["main"].monitor_type is None + assert view.async_update is False -def test_image_setup_image_auto(qtbot, mocked_client): - bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.image(monitor="eiger", monitor_type="auto") - assert bec_image_view.monitor == "eiger" - assert bec_image_view.subscriptions["main"].source == "auto" - assert bec_image_view.subscriptions["main"].monitor_type == "auto" - assert bec_image_view.main_image.raw_data is None - assert bec_image_view.main_image.image is None +def test_image_disconnects_with_missing_entry(qtbot, mocked_client): + view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + + view.image(device_name="eiger", device_entry="img") + assert view.device_name == "eiger" + assert view.device_entry == "img" + + view.image(device_name="eiger", device_entry=None) + assert view.device_name == "" + assert view.device_entry == "" + + +def test_handle_scan_change_clears_buffers_and_resets_crosshair(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0, 2.0])] + view.main_image.max_len = 2 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + reset_called = [] + if view.crosshair is not None: + monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True)) + + view._handle_scan_change("scan_2") + + assert view.old_scan_id == "scan_1" + assert view.scan_id == "scan_2" + assert clear_called == [True] + assert view.main_image.buffer == [] + assert view.main_image.max_len == 0 + if view.crosshair is not None: + assert reset_called == [True] + + +def test_handle_scan_change_reconnects_async(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.async_update = True + + called = [] + monkeypatch.setattr(view, "_setup_async_image", lambda scan_id: called.append(scan_id)) + + view._handle_scan_change("scan_2") + + assert called == ["scan_2"] + + +def test_handle_scan_change_same_scan_noop(qtbot, mocked_client, monkeypatch): + view = create_widget(qtbot, Image, client=mocked_client) + view.scan_id = "scan_1" + view.main_image.buffer = [np.array([1.0])] + view.main_image.max_len = 1 + + clear_called = [] + monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True)) + + view._handle_scan_change("scan_1") + + assert view.scan_id == "scan_1" + assert clear_called == [] + assert view.main_image.buffer == [np.array([1.0])] + assert view.main_image.max_len == 1 def test_image_data_update_2d(qtbot, mocked_client): @@ -245,8 +428,7 @@ def test_toolbar_actions_presence(qtbot, mocked_client): assert bec_image_view.toolbar.components.exists("image_autorange") assert bec_image_view.toolbar.components.exists("lock_aspect_ratio") assert bec_image_view.toolbar.components.exists("image_processing_fft") - assert bec_image_view.toolbar.components.exists("image_device_combo") - assert bec_image_view.toolbar.components.exists("image_dim_combo") + assert bec_image_view.toolbar.components.exists("device_selection") def test_auto_emit_syncs_image_toolbar_actions(qtbot, mocked_client): @@ -327,13 +509,40 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type): ################################### -def test_setup_image_from_toolbar(qtbot, mocked_client): +def test_setup_image_from_toolbar(qtbot, mocked_client, monkeypatch): bec_image_view = create_widget(qtbot, Image, client=mocked_client) - bec_image_view.device_combo_box.setCurrentText("eiger") - bec_image_view.dim_combo_box.setCurrentText("2d") + _set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + mocked_client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img", + { + "obj_name": "img", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) + ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) - assert bec_image_view.monitor == "eiger" + device_selection = bec_image_view.toolbar.components.get_action("device_selection").widget + device_selection.device_combo_box.update_devices_from_filters() + device_selection.device_combo_box.setCurrentText("eiger") + device_selection.signal_combo_box.setCurrentText("img") + + bec_image_view.on_device_selection_changed(None) + qtbot.wait(200) + + assert bec_image_view.device_name == "eiger" + assert bec_image_view.device_entry == "img" assert bec_image_view.subscriptions["main"].source == "device_monitor_2d" assert bec_image_view.subscriptions["main"].monitor_type == "2d" assert bec_image_view.main_image.raw_data is None @@ -598,90 +807,59 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): ############################################## -# MonitorSelectionToolbarBundle specific tests +# Device selection toolbar sync ############################################## -def test_monitor_selection_reverse_device_items(qtbot, mocked_client): - """ - Verify that _reverse_device_items correctly reverses the order of items in the - device combobox while preserving the current selection. - """ - view = create_widget(qtbot, Image, client=mocked_client) - combo = view.device_combo_box - - # Replace existing items with a deterministic list - combo.clear() - combo.addItem("samx", 1) - combo.addItem("samy", 2) - combo.addItem("samz", 3) - combo.setCurrentText("samy") - - # Reverse the items - view._reverse_device_items() - - # Order should be reversed and selection preserved - assert [combo.itemText(i) for i in range(combo.count())] == ["samz", "samy", "samx"] - assert combo.currentText() == "samy" - - -def test_monitor_selection_populate_preview_signals(qtbot, mocked_client, monkeypatch): - """ - Verify that _populate_preview_signals adds preview‑signal devices to the combo‑box - with the correct userData. - """ +def test_device_selection_syncs_from_properties(qtbot, mocked_client, monkeypatch): view = create_widget(qtbot, Image, client=mocked_client) - - # Provide a deterministic fake device_manager with get_bec_signals - class _FakeDM: - def get_bec_signals(self, _filter): - return [ - ("eiger", "img", {"obj_name": "eiger_img"}), - ("async_device", "img2", {"obj_name": "async_device_img2"}), + _set_signal_config(mocked_client, "eiger", "img2d", signal_class="PreviewSignal", ndim=2) + monkeypatch.setattr( + view.client.device_manager, + "get_bec_signals", + lambda signal_class_filter: ( + [ + ( + "eiger", + "img2d", + { + "obj_name": "img2d", + "signal_class": "PreviewSignal", + "describe": {"signal_info": {"ndim": 2}}, + }, + ) ] + if "PreviewSignal" in (signal_class_filter or []) + else [] + ), + ) - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) - - initial_count = view.device_combo_box.count() - - view._populate_preview_signals() + view.device_name = "eiger" + view.device_entry = "img2d" - # Two new entries should have been added - assert view.device_combo_box.count() == initial_count + 2 + qtbot.wait(200) # Allow signal processing - # The first newly added item should carry tuple userData describing the device/signal - data = view.device_combo_box.itemData(initial_count) - assert isinstance(data, tuple) and data[0] == "eiger" + device_selection = view.toolbar.components.get_action("device_selection").widget + qtbot.waitUntil( + lambda: device_selection.device_combo_box.currentText() == "eiger" + and device_selection.signal_combo_box.currentText() == "img2d", + timeout=1000, + ) -def test_monitor_selection_adjust_and_connect(qtbot, mocked_client, monkeypatch): - """ - Verify that _adjust_and_connect performs the full set-up: - - fills the combobox with preview signals, - - reverses their order, - - and resets the currentText to an empty string. - """ +def test_device_entry_syncs_from_toolbar(qtbot, mocked_client): view = create_widget(qtbot, Image, client=mocked_client) + _set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2) + _set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2) - # Deterministic fake device_manager - class _FakeDM: - def get_bec_signals(self, _filter): - return [("eiger", "img", {"obj_name": "eiger_img"})] - - monkeypatch.setattr(view.client, "device_manager", _FakeDM()) + view.device_name = "eiger" + view.device_entry = "img_a" - combo = view.device_combo_box - # Start from a clean state - combo.clear() - combo.addItem("", None) - combo.setCurrentText("") + device_selection = view.toolbar.components.get_action("device_selection").widget + device_selection.signal_combo_box.blockSignals(True) + device_selection.signal_combo_box.setCurrentText("img_b") + device_selection.signal_combo_box.blockSignals(False) - # Execute the method under test - view._adjust_and_connect() + view._sync_device_entry_from_toolbar() - # Expect exactly two items: preview label followed by the empty default - assert combo.count() == 2 - # Because of the reversal, the preview label comes first - assert combo.itemText(0) == "eiger_img" - # Current selection remains empty - assert combo.currentText() == "" + assert view.device_entry == "img_b" From aadb3e129a91c9babbc85bec5b9d76d7c341124f Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 29 Jan 2026 14:27:34 +0100 Subject: [PATCH 143/155] ci: cancel previous CI run for PR or branch --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 693a3a54c..5570667a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: required: false type: string +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: pull-requests: write From df4082b31b326fc6ea0b00596a805a1a95305106 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 30 Jan 2026 12:24:12 +0100 Subject: [PATCH 144/155] fix(editors): VSCode widget removed --- bec_widgets/cli/client.py | 7 - .../widgets/editors/vscode/__init__.py | 0 .../editors/vscode/register_vs_code_editor.py | 15 -- .../editors/vscode/vs_code_editor.pyproject | 1 - .../editors/vscode/vs_code_editor_plugin.py | 57 ----- bec_widgets/widgets/editors/vscode/vscode.py | 203 ------------------ tests/unit_tests/test_vscode_widget.py | 91 -------- 7 files changed, 374 deletions(-) delete mode 100644 bec_widgets/widgets/editors/vscode/__init__.py delete mode 100644 bec_widgets/widgets/editors/vscode/register_vs_code_editor.py delete mode 100644 bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject delete mode 100644 bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py delete mode 100644 bec_widgets/widgets/editors/vscode/vscode.py delete mode 100644 tests/unit_tests/test_vscode_widget.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 08b91c9db..5aa7001a4 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -56,7 +56,6 @@ class _WidgetsEnumType(str, enum.Enum): "ScatterWaveform": "ScatterWaveform", "SignalLabel": "SignalLabel", "TextBox": "TextBox", - "VSCodeEditor": "VSCodeEditor", "Waveform": "Waveform", "WebConsole": "WebConsole", "WebsiteWidget": "WebsiteWidget", @@ -5529,12 +5528,6 @@ def set_html_text(self, text: str) -> None: """ -class VSCodeEditor(RPCBase): - """A widget to display the VSCode editor.""" - - ... - - class Waveform(RPCBase): """Widget for plotting waveforms.""" diff --git a/bec_widgets/widgets/editors/vscode/__init__.py b/bec_widgets/widgets/editors/vscode/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bec_widgets/widgets/editors/vscode/register_vs_code_editor.py b/bec_widgets/widgets/editors/vscode/register_vs_code_editor.py deleted file mode 100644 index 06cbcce44..000000000 --- a/bec_widgets/widgets/editors/vscode/register_vs_code_editor.py +++ /dev/null @@ -1,15 +0,0 @@ -def main(): # pragma: no cover - from qtpy import PYSIDE6 - - if not PYSIDE6: - print("PYSIDE6 is not available in the environment. Cannot patch designer.") - return - from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - - from bec_widgets.widgets.editors.vscode.vs_code_editor_plugin import VSCodeEditorPlugin - - QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject b/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject deleted file mode 100644 index 9c5276022..000000000 --- a/bec_widgets/widgets/editors/vscode/vs_code_editor.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['vscode.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py b/bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py deleted file mode 100644 index 4614210bc..000000000 --- a/bec_widgets/widgets/editors/vscode/vs_code_editor_plugin.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (C) 2022 The Qt Company Ltd. -# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause - -from qtpy.QtDesigner import QDesignerCustomWidgetInterface -from qtpy.QtWidgets import QWidget - -from bec_widgets.utils.bec_designer import designer_material_icon -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor - -DOM_XML = """ - - - - -""" - - -class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover - def __init__(self): - super().__init__() - self._form_editor = None - - def createWidget(self, parent): - if parent is None: - return QWidget() - t = VSCodeEditor(parent) - return t - - def domXml(self): - return DOM_XML - - def group(self): - return "BEC Developer" - - def icon(self): - return designer_material_icon(VSCodeEditor.ICON_NAME) - - def includeFile(self): - return "vs_code_editor" - - def initialize(self, form_editor): - self._form_editor = form_editor - - def isContainer(self): - return False - - def isInitialized(self): - return self._form_editor is not None - - def name(self): - return "VSCodeEditor" - - def toolTip(self): - return "" - - def whatsThis(self): - return self.toolTip() diff --git a/bec_widgets/widgets/editors/vscode/vscode.py b/bec_widgets/widgets/editors/vscode/vscode.py deleted file mode 100644 index 85584dd36..000000000 --- a/bec_widgets/widgets/editors/vscode/vscode.py +++ /dev/null @@ -1,203 +0,0 @@ -import os -import select -import shlex -import signal -import socket -import subprocess -from typing import Literal - -from pydantic import BaseModel -from qtpy.QtCore import Signal, Slot - -from bec_widgets.widgets.editors.website.website import WebsiteWidget - - -class VSCodeInstructionMessage(BaseModel): - command: Literal["open", "write", "close", "zenMode", "save", "new", "setCursor"] - content: str = "" - - -def get_free_port(): - """ - Get a free port on the local machine. - - Returns: - int: The free port number - """ - sock = socket.socket() - sock.bind(("", 0)) - port = sock.getsockname()[1] - sock.close() - return port - - -class VSCodeEditor(WebsiteWidget): - """ - A widget to display the VSCode editor. - """ - - file_saved = Signal(str) - - token = "bec" - host = "127.0.0.1" - - PLUGIN = True - USER_ACCESS = [] - ICON_NAME = "developer_mode_tv" - - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): - - self.process = None - self.port = get_free_port() - self._url = f"http://{self.host}:{self.port}?tkn={self.token}" - super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs) - self.start_server() - self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}") - - def start_server(self): - """ - Start the server. - - This method starts the server for the VSCode editor in a subprocess. - """ - - env = os.environ.copy() - env["BEC_Widgets_GUIID"] = self.gui_id - env["BEC_REDIS_HOST"] = self.client.connector.host - cmd = shlex.split( - f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms" - ) - self.process = subprocess.Popen( - cmd, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - preexec_fn=os.setsid, - env=env, - ) - - os.set_blocking(self.process.stdout.fileno(), False) - while self.process.poll() is None: - readylist, _, _ = select.select([self.process.stdout], [], [], 1) - if self.process.stdout in readylist: - output = self.process.stdout.read(1024) - if output and f"available at {self._url}" in output: - break - self.set_url(self._url) - self.wait_until_loaded() - - @Slot(str) - def open_file(self, file_path: str): - """ - Open a file in the VSCode editor. - - Args: - file_path: The file path to open - """ - msg = VSCodeInstructionMessage(command="open", content=f"file://{file_path}") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot(dict, dict) - def on_vscode_event(self, content, _metadata): - """ - Handle the VSCode event. VSCode events are received as RawMessages. - - Args: - content: The content of the event - metadata: The metadata of the event - """ - - # the message also contains the content but I think is fine for now to just emit the file path - if not isinstance(content["data"], dict): - return - if "uri" not in content["data"]: - return - if not content["data"]["uri"].startswith("file://"): - return - file_path = content["data"]["uri"].split("file://")[1] - self.file_saved.emit(file_path) - - @Slot() - def save_file(self): - """ - Save the file in the VSCode editor. - """ - msg = VSCodeInstructionMessage(command="save") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot() - def new_file(self): - """ - Create a new file in the VSCode editor. - """ - msg = VSCodeInstructionMessage(command="new") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot() - def close_file(self): - """ - Close the file in the VSCode editor. - """ - msg = VSCodeInstructionMessage(command="close") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot(str) - def write_file(self, content: str): - """ - Write content to the file in the VSCode editor. - - Args: - content: The content to write - """ - msg = VSCodeInstructionMessage(command="write", content=content) - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot() - def zen_mode(self): - """ - Toggle the Zen mode in the VSCode editor. - """ - msg = VSCodeInstructionMessage(command="zenMode") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - @Slot(int, int) - def set_cursor(self, line: int, column: int): - """ - Set the cursor in the VSCode editor. - - Args: - line: The line number - column: The column number - """ - msg = VSCodeInstructionMessage(command="setCursor", content=f"{line},{column}") - self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json()) - - def cleanup_vscode(self): - """ - Cleanup the VSCode editor. - """ - if not self.process or self.process.poll() is not None: - return - os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) - self.process.wait() - - def cleanup(self): - """ - Cleanup the widget. This method is called from the dock area when the widget is removed. - """ - self.bec_dispatcher.disconnect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}") - self.cleanup_vscode() - return super().cleanup() - - -if __name__ == "__main__": # pragma: no cover - import sys - - from qtpy.QtWidgets import QApplication - - app = QApplication(sys.argv) - widget = VSCodeEditor(gui_id="unknown") - widget.show() - app.exec_() - widget.bec_dispatcher.disconnect_all() - widget.client.shutdown() diff --git a/tests/unit_tests/test_vscode_widget.py b/tests/unit_tests/test_vscode_widget.py deleted file mode 100644 index d4210cba1..000000000 --- a/tests/unit_tests/test_vscode_widget.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -import shlex -import subprocess -from unittest import mock - -import pytest - -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor - -from .client_mocks import mocked_client - - -@pytest.fixture -def vscode_widget(qtbot, mocked_client): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.subprocess.Popen") as mock_popen: - widget = VSCodeEditor(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_vscode_widget(qtbot, vscode_widget): - assert vscode_widget.process is not None - assert vscode_widget._url == f"http://127.0.0.1:{vscode_widget.port}?tkn=bec" - - -def test_start_server(qtbot, mocked_client): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.killpg") as mock_killpg: - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.getpgid") as mock_getpgid: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.subprocess.Popen" - ) as mock_popen: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.select.select" - ) as mock_select: - with mock.patch( - "bec_widgets.widgets.editors.vscode.vscode.get_free_port" - ) as mock_get_free_port: - mock_get_free_port.return_value = 12345 - mock_process = mock.Mock() - mock_process.stdout.fileno.return_value = 1 - mock_process.poll.return_value = None - mock_process.stdout.read.return_value = f"available at http://{VSCodeEditor.host}:{12345}?tkn={VSCodeEditor.token}" - mock_popen.return_value = mock_process - mock_select.return_value = [[mock_process.stdout], [], []] - - widget = VSCodeEditor(client=mocked_client) - widget.close() - widget.deleteLater() - - assert ( - mock.call( - shlex.split( - f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms" - ), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - preexec_fn=os.setsid, - env=mock.ANY, - ) - in mock_popen.mock_calls - ) - - -@pytest.fixture -def patched_vscode_process(qtbot, vscode_widget): - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.killpg") as mock_killpg: - mock_killpg.reset_mock() - with mock.patch("bec_widgets.widgets.editors.vscode.vscode.os.getpgid") as mock_getpgid: - mock_getpgid.return_value = 123 - vscode_widget.process = mock.Mock() - yield vscode_widget, mock_killpg - - -def test_vscode_cleanup(qtbot, patched_vscode_process): - vscode_patched, mock_killpg = patched_vscode_process - vscode_patched.process.pid = 123 - vscode_patched.process.poll.return_value = None - vscode_patched.cleanup_vscode() - mock_killpg.assert_called_once_with(123, 15) - vscode_patched.process.wait.assert_called_once() - - -def test_close_event_on_terminated_code(qtbot, patched_vscode_process): - vscode_patched, mock_killpg = patched_vscode_process - vscode_patched.process.pid = 123 - vscode_patched.process.poll.return_value = 0 - vscode_patched.cleanup_vscode() - mock_killpg.assert_not_called() - vscode_patched.process.wait.assert_not_called() From 577ca4301a9df332d610dc044fe7b4f05cce390e Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 29 Jan 2026 13:47:29 +0100 Subject: [PATCH 145/155] fix(ophyd-validation): add device_manager_ds argument if available for ophyd validation --- .../components/ophyd_validation/ophyd_validation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index f6ff0dd80..a2cae41a0 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -69,11 +69,12 @@ def __init__( enable_connect: bool, force_connect: bool, timeout: float, + device_manager_ds: object | None = None, ): super().__init__() self.uuid = device_model.uuid test_config = {device_model.device_name: device_model.device_config} - self.tester = StaticDeviceTest(config_dict=test_config) + self.tester = StaticDeviceTest(config_dict=test_config, device_manager_ds=device_manager_ds) self.signals = DeviceTestResult() self.device_config = device_model.device_config self.enable_connect = enable_connect @@ -752,11 +753,15 @@ def _submit_test( # Remove widget from list as it's safe to assume it can be loaded. self._remove_device_config(widget.device_model.device_config) return + dm_ds = None + if self.client: + dm_ds = getattr(self.client, "device_manager", None) runnable = DeviceTest( device_model=widget.device_model, enable_connect=connect, force_connect=force_connect, timeout=timeout, + device_manager_ds=dm_ds, ) widget.validation_scheduled() if self.thread_pool_manager: From 40a666aa18624885bab5cfde44f1ad5f830af1c7 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 30 Jan 2026 12:29:12 +0100 Subject: [PATCH 146/155] refactor(dock_area): change name to BECDockArea --- bec_widgets/applications/bw_launch.py | 10 +- bec_widgets/applications/launch_window.py | 9 +- bec_widgets/applications/main_app.py | 4 +- .../views/developer_view/developer_widget.py | 6 +- .../device_manager_display_widget.py | 2 +- bec_widgets/cli/client.py | 114 +++++++++--------- .../containers/auto_update/auto_updates.py | 4 +- .../__init__.py | 0 .../basic_dock_area.py | 0 .../dock_area.py} | 18 ++- .../profile_utils.py | 0 .../settings/__init__.py | 0 .../settings/dialogs.py | 0 .../settings/workspace_manager.py | 2 +- .../toolbar_components/__init__.py | 0 .../toolbar_components/workspace_actions.py | 2 +- .../widgets/editors/monaco/monaco_dock.py | 2 +- tests/end-2-end/test_bec_dock_rpc_e2e.py | 4 +- tests/unit_tests/test_client_utils.py | 4 +- ...dvanced_dock_area.py => test_dock_area.py} | 66 +++++----- tests/unit_tests/test_rpc_widget_handler.py | 2 +- 21 files changed, 118 insertions(+), 131 deletions(-) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/__init__.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/basic_dock_area.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area/advanced_dock_area.py => dock_area/dock_area.py} (98%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/profile_utils.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/settings/__init__.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/settings/dialogs.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/settings/workspace_manager.py (99%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/toolbar_components/__init__.py (100%) rename bec_widgets/widgets/containers/{advanced_dock_area => dock_area}/toolbar_components/workspace_actions.py (98%) rename tests/unit_tests/{test_advanced_dock_area.py => test_dock_area.py} (96%) diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 1a753edb1..8b8918498 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -2,15 +2,15 @@ from bec_lib import bec_logger -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea logger = bec_logger.logger def dock_area( object_name: str | None = None, profile: str | None = None, start_empty: bool = False -) -> AdvancedDockArea: +) -> BECDockArea: """ Create an advanced dock area using Qt Advanced Docking System. @@ -20,7 +20,7 @@ def dock_area( start_empty(bool): If True, start with an empty dock area when loading specified profile. Returns: - AdvancedDockArea: The created advanced dock area. + BECDockArea: The created advanced dock area. Note: The "general" profile is mandatory and will always exist. If manually deleted, @@ -29,7 +29,7 @@ def dock_area( # Default to "general" profile when called from CLI without specifying a profile effective_profile = profile if profile is not None else "general" - widget = AdvancedDockArea( + widget = BECDockArea( object_name=object_name, restore_initial_profile=True, root_widget=True, @@ -51,7 +51,7 @@ def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates: object_name(str): The name of the dock area. Returns: - AdvancedDockArea: The created dock area. + BECDockArea: The created dock area. """ _auto_update = AutoUpdates(object_name=object_name) return _auto_update diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index cb53555d5..0386690ec 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -29,12 +29,9 @@ from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - get_last_profile, - list_profiles, -) from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea +from bec_widgets.widgets.containers.dock_area.profile_utils import get_last_profile, list_profiles from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton @@ -428,7 +425,7 @@ def launch( from bec_widgets.applications import bw_launch with RPCRegister.delayed_broadcast() as rpc_register: - existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea) + existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea) if name is not None: WidgetContainerUtils.raise_for_invalid_name(name) # If name already exists, generate a unique one with counter suffix diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 06e9a6000..aef39ff80 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -7,7 +7,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow @@ -45,7 +45,7 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") - self.ads = AdvancedDockArea(self, profile_namespace="bec", auto_profile_namespace=False) + self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False) self.ads.setObjectName("MainWorkspace") self.device_manager = DeviceManagerView(self) self.developer_view = DeveloperView(self) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 8e4f42f18..96b6741ff 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -13,8 +13,8 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget @@ -99,7 +99,7 @@ def __init__(self, parent=None, **kwargs): self.monaco = MonacoDock(self) self.monaco.setObjectName("MonacoEditor") self.monaco.save_enabled.connect(self._on_save_enabled_update) - self.plotting_ads = AdvancedDockArea( + self.plotting_ads = BECDockArea( self, mode="plot", default_add_direction="bottom", diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 10d49d41b..45dfc5eb1 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -38,7 +38,7 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.control.device_manager.components import ( DeviceTable, DMConfigView, diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 5aa7001a4..5ed4e988b 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -90,7 +90,63 @@ class _WidgetsEnumType(str, enum.Enum): logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}") -class AdvancedDockArea(RPCBase): +class AutoUpdates(RPCBase): + @property + @rpc_call + def enabled(self) -> "bool": + """ + Get the enabled status of the auto updates. + """ + + @enabled.setter + @rpc_call + def enabled(self) -> "bool": + """ + Get the enabled status of the auto updates. + """ + + @property + @rpc_call + def selected_device(self) -> "str | None": + """ + Get the selected device from the auto update config. + + Returns: + str: The selected device. If no device is selected, None is returned. + """ + + @selected_device.setter + @rpc_call + def selected_device(self) -> "str | None": + """ + Get the selected device from the auto update config. + + Returns: + str: The selected device. If no device is selected, None is returned. + """ + + +class AvailableDeviceResources(RPCBase): + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + +class BECDockArea(RPCBase): @rpc_call def new( self, @@ -320,62 +376,6 @@ def delete_profile(self, name: "str | None" = None, show_dialog: "bool" = False) """ -class AutoUpdates(RPCBase): - @property - @rpc_call - def enabled(self) -> "bool": - """ - Get the enabled status of the auto updates. - """ - - @enabled.setter - @rpc_call - def enabled(self) -> "bool": - """ - Get the enabled status of the auto updates. - """ - - @property - @rpc_call - def selected_device(self) -> "str | None": - """ - Get the selected device from the auto update config. - - Returns: - str: The selected device. If no device is selected, None is returned. - """ - - @selected_device.setter - @rpc_call - def selected_device(self) -> "str | None": - """ - Get the selected device from the auto update config. - - Returns: - str: The selected device. If no device is selected, None is returned. - """ - - -class AvailableDeviceResources(RPCBase): - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - @rpc_call - def attach(self): - """ - None - """ - - @rpc_call - def detach(self): - """ - Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. - """ - - class BECMainWindow(RPCBase): @rpc_call def remove(self): diff --git a/bec_widgets/widgets/containers/auto_update/auto_updates.py b/bec_widgets/widgets/containers/auto_update/auto_updates.py index 9f75c7993..e3c7a7092 100644 --- a/bec_widgets/widgets/containers/auto_update/auto_updates.py +++ b/bec_widgets/widgets/containers/auto_update/auto_updates.py @@ -7,7 +7,7 @@ from bec_lib.messages import ScanStatusMessage from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow from bec_widgets.widgets.containers.qt_ads import CDockWidget @@ -37,7 +37,7 @@ def __init__( ): super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs) - self.dock_area = AdvancedDockArea( + self.dock_area = BECDockArea( parent=self, object_name="dock_area", enable_profile_management=False, diff --git a/bec_widgets/widgets/containers/advanced_dock_area/__init__.py b/bec_widgets/widgets/containers/dock_area/__init__.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/__init__.py rename to bec_widgets/widgets/containers/dock_area/__init__.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py rename to bec_widgets/widgets/containers/dock_area/basic_dock_area.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py similarity index 98% rename from bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py rename to bec_widgets/widgets/containers/dock_area/dock_area.py index 143ab04e9..129b24f92 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -5,7 +5,7 @@ import slugify from bec_lib import bec_logger -from qtpy.QtCore import QTimer, Signal +from qtpy.QtCore import Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( QApplication, @@ -31,8 +31,8 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_state_manager import WidgetStateManager -from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( +from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, default_profile_candidates, delete_profile_files, @@ -55,14 +55,12 @@ user_profile_candidates, write_manifest, ) -from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( +from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( RestoreProfileDialog, SaveProfileDialog, ) -from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( - WorkSpaceManager, -) -from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import ( +from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager +from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actions import ( WorkspaceConnection, workspace_bundle, ) @@ -90,7 +88,7 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} -class AdvancedDockArea(DockAreaWidget): +class BECDockArea(DockAreaWidget): RPC = True PLUGIN = False USER_ACCESS = [ @@ -1163,7 +1161,7 @@ def cleanup(self): dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True) + ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True) window.setCentralWidget(ads) window.show() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py rename to bec_widgets/widgets/containers/dock_area/profile_utils.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py b/bec_widgets/widgets/containers/dock_area/settings/__init__.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py rename to bec_widgets/widgets/containers/dock_area/settings/__init__.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/dock_area/settings/dialogs.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py rename to bec_widgets/widgets/containers/dock_area/settings/dialogs.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py similarity index 99% rename from bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py rename to bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py index be9e55426..a4f3a3fc6 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py @@ -28,7 +28,7 @@ from bec_widgets import BECWidget, SafeSlot from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( +from bec_widgets.widgets.containers.dock_area.profile_utils import ( get_profile_info, is_quick_select, list_profiles, diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/__init__.py similarity index 100% rename from bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py rename to bec_widgets/widgets/containers/dock_area/toolbar_components/__init__.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py similarity index 98% rename from bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py rename to bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index 9f7fd758c..3eea7237d 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -10,7 +10,7 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles +from bec_widgets.widgets.containers.dock_area.profile_utils import list_quick_profiles class ProfileComboBox(QComboBox): diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index a2b738a2e..663a90f78 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -9,7 +9,7 @@ from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget -from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index 81a9853d5..55c45a8dd 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -122,12 +122,12 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot): assert gui.windows["bec"] is gui.bec mw = gui.bec assert mw.__class__.__name__ == "RPCReference" - assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "AdvancedDockArea" + assert gui._ipython_registry[mw._gui_id].__class__.__name__ == "BECDockArea" xw = gui.new("X") xw.delete_all() assert xw.__class__.__name__ == "RPCReference" - assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "AdvancedDockArea" + assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea" assert len(gui.windows) == 2 assert gui._gui_is_alive() diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 058ccdc7d..db2589e81 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -3,13 +3,13 @@ import pytest -from bec_widgets.cli.client import AdvancedDockArea +from bec_widgets.cli.client import BECDockArea from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process @pytest.fixture def cli_dock_area(): - dock_area = AdvancedDockArea(gui_id="test") + dock_area = BECDockArea(gui_id="test") with mock.patch.object(dock_area, "_run_rpc") as mock_rpc_call: with mock.patch.object(dock_area, "_gui_is_alive", return_value=True): yield dock_area, mock_rpc_call diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_dock_area.py similarity index 96% rename from tests/unit_tests/test_advanced_dock_area.py rename to tests/unit_tests/test_dock_area.py index c77ac543a..8b6a309da 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -10,17 +10,14 @@ from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QDialog, QMessageBox, QWidget -import bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area as basic_dock_module -import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( - AdvancedDockArea, - SaveProfileDialog, -) -from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import ( +import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module +import bec_widgets.widgets.containers.dock_area.profile_utils as profile_utils +from bec_widgets.widgets.containers.dock_area.basic_dock_area import ( DockAreaWidget, DockSettingsDialog, ) -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog +from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, default_profile_path, get_profile_info, @@ -31,20 +28,17 @@ load_user_profile_screenshot, open_default_settings, open_user_settings, - plugin_profiles_dir, read_manifest, restore_user_from_default, set_quick_select, user_profile_path, write_manifest, ) -from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( +from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( PreviewPanel, RestoreProfileDialog, ) -from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( - WorkSpaceManager, -) +from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager from .client_mocks import mocked_client @@ -52,7 +46,7 @@ @pytest.fixture def advanced_dock_area(qtbot, mocked_client): """Create an AdvancedDockArea instance for testing.""" - widget = AdvancedDockArea(client=mocked_client) + widget = BECDockArea(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -152,7 +146,7 @@ def delete_profile(self, name: str, show_dialog: bool = False) -> bool: """Mock delete_profile that performs actual file deletion.""" from qtpy.QtWidgets import QMessageBox - from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + from bec_widgets.widgets.containers.dock_area.profile_utils import ( delete_profile_files, is_profile_read_only, ) @@ -190,7 +184,7 @@ def basic_dock_area(qtbot, mocked_client): class _NamespaceProfiles: """Helper that routes profile file helpers through a namespace.""" - def __init__(self, widget: AdvancedDockArea): + def __init__(self, widget: BECDockArea): self.namespace = widget.profile_namespace def open_user(self, name: str): @@ -215,7 +209,7 @@ def is_quick_select(self, name: str) -> bool: return is_quick_select(name, namespace=self.namespace) -def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles: +def profile_helper(widget: BECDockArea) -> _NamespaceProfiles: """Return a helper wired to the widget's profile namespace.""" return _NamespaceProfiles(widget) @@ -590,7 +584,7 @@ class TestAdvancedDockAreaInit: def test_init(self, advanced_dock_area): assert advanced_dock_area is not None - assert isinstance(advanced_dock_area, AdvancedDockArea) + assert isinstance(advanced_dock_area, BECDockArea) assert advanced_dock_area.mode == "creator" assert hasattr(advanced_dock_area, "dock_manager") assert hasattr(advanced_dock_area, "toolbar") @@ -598,8 +592,8 @@ def test_init(self, advanced_dock_area): assert hasattr(advanced_dock_area, "state_manager") def test_rpc_and_plugin_flags(self): - assert AdvancedDockArea.RPC is True - assert AdvancedDockArea.PLUGIN is False + assert BECDockArea.RPC is True + assert BECDockArea.PLUGIN is False def test_user_access_list(self): expected_methods = [ @@ -611,7 +605,7 @@ def test_user_access_list(self): "delete_all", ] for method in expected_methods: - assert method in AdvancedDockArea.USER_ACCESS + assert method in BECDockArea.USER_ACCESS class TestDockManagement: @@ -1421,21 +1415,21 @@ def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area pix = QPixmap(8, 8) pix.fill(Qt.red) monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot", lambda name, namespace=None: pix, ) monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", lambda name, namespace=None: pix, ) monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", lambda *args, **kwargs: True, ) with ( patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" ) as mock_restore, patch.object(advanced_dock_area, "delete_all") as mock_delete_all, patch.object(advanced_dock_area, "load_profile") as mock_load_profile, @@ -1457,20 +1451,20 @@ def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_are advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot", lambda name: QPixmap(), ) monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", lambda name: QPixmap(), ) monkeypatch.setattr( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", lambda *args, **kwargs: False, ) with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" ) as mock_restore: advanced_dock_area.restore_user_profile_from_default() @@ -1479,7 +1473,7 @@ def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_are def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): advanced_dock_area._current_profile_name = None with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm" + "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm" ) as mock_confirm: advanced_dock_area.restore_user_profile_from_default() mock_confirm.assert_not_called() @@ -1723,8 +1717,7 @@ def is_quick_select(self): return False with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", - StubDialog, + "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog ): advanced_dock_area.save_profile(profile_name, show_dialog=True) @@ -1795,8 +1788,7 @@ def is_quick_select(self): return False with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", - StubDialog, + "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog ): advanced_dock_area.save_profile(show_dialog=True) @@ -1859,11 +1851,11 @@ def test_delete_profile_readonly( with ( patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question", + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question", return_value=QMessageBox.Yes, ) as mock_question, patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information", + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.information", return_value=None, ) as mock_info, ): @@ -1893,7 +1885,7 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): mock_get_action.return_value.widget = mock_combo with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + "bec_widgets.widgets.containers.dock_area.dock_area.QMessageBox.question" ) as mock_question: mock_question.return_value = QMessageBox.Yes diff --git a/tests/unit_tests/test_rpc_widget_handler.py b/tests/unit_tests/test_rpc_widget_handler.py index 558e44e53..ed213b8bb 100644 --- a/tests/unit_tests/test_rpc_widget_handler.py +++ b/tests/unit_tests/test_rpc_widget_handler.py @@ -9,7 +9,7 @@ def test_rpc_widget_handler(): handler = RPCWidgetHandler() assert "Image" in handler.widget_classes assert "RingProgressBar" in handler.widget_classes - assert "AdvancedDockArea" in handler.widget_classes + assert "BECDockArea" in handler.widget_classes class _TestPluginWidget(BECWidget): ... From 572797626cffd3274de3d47861437cdfddd78346 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 30 Jan 2026 15:33:46 +0100 Subject: [PATCH 147/155] fix(screen_utils): screen utilities added and fixed sizing for widgets from launch window and main app --- bec_widgets/applications/launch_window.py | 86 +++++++++---------- bec_widgets/applications/main_app.py | 30 +++---- bec_widgets/utils/screen_utils.py | 100 ++++++++++++++++++++++ tests/unit_tests/test_screen_utils.py | 38 ++++++++ 4 files changed, 189 insertions(+), 65 deletions(-) create mode 100644 bec_widgets/utils/screen_utils.py create mode 100644 tests/unit_tests/test_screen_utils.py diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 0386690ec..03a7bea22 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -27,6 +27,7 @@ from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.plugin_utils import get_plugin_auto_updates from bec_widgets.utils.round_frame import RoundedFrame +from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates @@ -75,23 +76,28 @@ def __init__( circular_pixmap.fill(Qt.transparent) painter = QPainter(circular_pixmap) - painter.setRenderHints(QPainter.Antialiasing, True) + painter.setRenderHints(QPainter.RenderHint.Antialiasing, True) path = QPainterPath() path.addEllipse(0, 0, size, size) painter.setClipPath(path) - pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + pixmap = pixmap.scaled( + size, + size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) painter.drawPixmap(0, 0, pixmap) painter.end() self.icon_label.setPixmap(circular_pixmap) - self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter) + self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter) # Top label self.top_label = QLabel(top_label.upper()) font_top = self.top_label.font() font_top.setPointSize(10) self.top_label.setFont(font_top) - self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter) + self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter) # Main label self.main_label = QLabel(main_label) @@ -101,7 +107,7 @@ def __init__( font_main.setPointSize(14) font_main.setBold(True) self.main_label.setFont(font_main) - self.main_label.setAlignment(Qt.AlignCenter) + self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Shrink font if the default would wrap on this platform / DPI content_width = ( @@ -117,13 +123,13 @@ def __init__( self.layout.addWidget(self.main_label) - self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed) + self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.layout.addItem(self.spacer_top) # Description self.description_label = QLabel(description) self.description_label.setWordWrap(True) - self.description_label.setAlignment(Qt.AlignCenter) + self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.description_label) # Selector @@ -133,7 +139,9 @@ def __init__( else: self.selector = None - self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding) + self.spacer_bottom = QSpacerItem( + 0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) self.layout.addItem(self.spacer_bottom) # Action button @@ -153,7 +161,7 @@ def __init__( } """ ) - self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter) + self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter) def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): """ @@ -176,12 +184,13 @@ def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): metrics = QFontMetrics(font) label.setFont(font) label.setWordWrap(False) - label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width)) + label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.ElideRight, max_width)) class LaunchWindow(BECMainWindow): RPC = True TILE_SIZE = (250, 300) + DEFAULT_LAUNCH_SIZE = (800, 600) USER_ACCESS = ["show_launcher", "hide_launcher"] def __init__( @@ -206,7 +215,7 @@ def __init__( self.toolbar = ModularToolBar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.toolbar) self.spacer = QWidget(self) - self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.toolbar.addWidget(self.spacer) self.toolbar.addWidget(self.dark_mode_button) @@ -315,7 +324,7 @@ def register_tile( ) tile.setFixedWidth(self.TILE_SIZE[0]) tile.setMinimumHeight(self.TILE_SIZE[1]) - tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding) if action_button: tile.action_button.clicked.connect(action_button) if show_selector and selector_items: @@ -425,6 +434,8 @@ def launch( from bec_widgets.applications import bw_launch with RPCRegister.delayed_broadcast() as rpc_register: + if geometry is None and launch_script != "custom_ui_file": + geometry = self._default_launch_geometry() existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea) if name is not None: WidgetContainerUtils.raise_for_invalid_name(name) @@ -448,13 +459,13 @@ def launch( if launch_script == "auto_update": auto_update = kwargs.pop("auto_update", None) - return self._launch_auto_update(auto_update) + return self._launch_auto_update(auto_update, geometry=geometry) if launch_script == "widget": widget = kwargs.pop("widget", None) if widget is None: raise ValueError("Widget name must be provided.") - return self._launch_widget(widget) + return self._launch_widget(widget, geometry=geometry) launch = getattr(bw_launch, launch_script, None) if launch is None: @@ -466,13 +477,13 @@ def launch( logger.info(f"Created new dock area: {name}") if isinstance(result_widget, BECMainWindow): - self._apply_window_geometry(result_widget, geometry) + apply_window_geometry(result_widget, geometry) result_widget.show() else: window = BECMainWindowNoRPC() window.setCentralWidget(result_widget) window.setWindowTitle(f"BEC - {result_widget.objectName()}") - self._apply_window_geometry(window, geometry) + apply_window_geometry(window, geometry) window.show() return result_widget @@ -508,12 +519,14 @@ def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow: window.setCentralWidget(loaded) window.setWindowTitle(f"BEC - {filename}") - self._apply_window_geometry(window, None) + apply_window_geometry(window, None) window.show() logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}") return window - def _launch_auto_update(self, auto_update: str) -> AutoUpdates: + def _launch_auto_update( + self, auto_update: str, geometry: tuple[int, int, int, int] | None = None + ) -> AutoUpdates: if auto_update in self.available_auto_updates: auto_update_cls = self.available_auto_updates[auto_update] window = auto_update_cls() @@ -524,11 +537,13 @@ def _launch_auto_update(self, auto_update: str) -> AutoUpdates: window.resize(window.minimumSizeHint()) window.setWindowTitle(f"BEC - {window.objectName()}") - self._apply_window_geometry(window, None) + apply_window_geometry(window, geometry) window.show() return window - def _launch_widget(self, widget: type[BECWidget]) -> QWidget: + def _launch_widget( + self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None + ) -> QWidget: name = pascal_to_snake(widget.__name__) WidgetContainerUtils.raise_for_invalid_name(name) @@ -541,7 +556,7 @@ def _launch_widget(self, widget: type[BECWidget]) -> QWidget: window.setCentralWidget(widget_instance) window.resize(window.minimumSizeHint()) window.setWindowTitle(f"BEC - {widget_instance.objectName()}") - self._apply_window_geometry(window, None) + apply_window_geometry(window, geometry) window.show() return window @@ -589,30 +604,9 @@ def _open_widget(self): raise ValueError(f"Widget {widget} not found in available widgets.") return self.launch("widget", widget=self.available_widgets[widget]) - def _apply_window_geometry( - self, window: QWidget, geometry: tuple[int, int, int, int] | None - ) -> None: - """Apply a provided geometry or center the window with an 80% layout.""" - if geometry is not None: - window.setGeometry(*geometry) - return - default_geometry = self._default_window_geometry(window) - if default_geometry is not None: - window.setGeometry(*default_geometry) - else: - window.resize(window.minimumSizeHint()) - - @staticmethod - def _default_window_geometry(window: QWidget) -> tuple[int, int, int, int] | None: - screen = window.screen() or QApplication.primaryScreen() - if screen is None: - return None - available = screen.availableGeometry() - width = int(available.width() * 0.8) - height = int(available.height() * 0.8) - x = available.x() + (available.width() - width) // 2 - y = available.y() + (available.height() - height) // 2 - return x, y, width, height + def _default_launch_geometry(self) -> tuple[int, int, int, int] | None: + width, height = self.DEFAULT_LAUNCH_SIZE + return centered_geometry_for_app(width=width, height=height) @SafeSlot(popup_error=True) def _open_custom_ui_file(self): @@ -703,7 +697,7 @@ def closeEvent(self, event): self.hide() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys from bec_widgets.utils.colors import apply_theme diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index aef39ff80..163132dd6 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -7,6 +7,11 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.screen_utils import ( + apply_centered_size, + available_screen_geometry, + main_app_size_for_screen, +) from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow @@ -211,25 +216,12 @@ def main(): # pragma: no cover apply_theme("dark") w = BECMainApp(show_examples=args.examples) - screen = app.primaryScreen() - screen_geometry = screen.availableGeometry() - screen_width = screen_geometry.width() - screen_height = screen_geometry.height() - # 70% of screen height, keep 16:9 ratio - height = int(screen_height * 0.9) - width = int(height * (16 / 9)) - - # If width exceeds screen width, scale down - if width > screen_width * 0.9: - width = int(screen_width * 0.9) - height = int(width / (16 / 9)) - - w.resize(width, height) - - # Center the window on the screen - x = screen_geometry.x() + (screen_geometry.width() - width) // 2 - y = screen_geometry.y() + (screen_geometry.height() - height) // 2 - w.move(x, y) + screen_geometry = available_screen_geometry() + if screen_geometry is not None: + width, height = main_app_size_for_screen(screen_geometry) + apply_centered_size(w, width, height, available=screen_geometry) + else: + w.resize(w.minimumSizeHint()) w.show() diff --git a/bec_widgets/utils/screen_utils.py b/bec_widgets/utils/screen_utils.py new file mode 100644 index 000000000..122086c65 --- /dev/null +++ b/bec_widgets/utils/screen_utils.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QApplication, QWidget + +if TYPE_CHECKING: # pragma: no cover + from qtpy.QtCore import QRect + + +def available_screen_geometry(*, widget: QWidget | None = None) -> QRect | None: + """ + Get the available geometry of the screen associated with the given widget or application. + + Args: + widget(QWidget | None): The widget to get the screen from. + Returns: + QRect | None: The available geometry of the screen, or None if no screen is found. + """ + screen = widget.screen() if widget is not None else None + if screen is None: + app = QApplication.instance() + screen = app.primaryScreen() if app is not None else None + if screen is None: + return None + return screen.availableGeometry() + + +def centered_geometry(available: "QRect", width: int, height: int) -> tuple[int, int, int, int]: + """ + Calculate centered geometry within the available rectangle. + + Args: + available(QRect): The available rectangle to center within. + width(int): The desired width. + height(int): The desired height. + + Returns: + tuple[int, int, int, int]: The (x, y, width, height) of the centered geometry. + """ + x = available.x() + (available.width() - width) // 2 + y = available.y() + (available.height() - height) // 2 + return x, y, width, height + + +def centered_geometry_for_app(width: int, height: int) -> tuple[int, int, int, int] | None: + available = available_screen_geometry() + if available is None: + return None + return centered_geometry(available, width, height) + + +def scaled_centered_geometry_for_window( + window: QWidget, *, width_ratio: float = 0.8, height_ratio: float = 0.8 +) -> tuple[int, int, int, int] | None: + available = available_screen_geometry(widget=window) + if available is None: + return None + width = int(available.width() * width_ratio) + height = int(available.height() * height_ratio) + return centered_geometry(available, width, height) + + +def apply_window_geometry( + window: QWidget, + geometry: tuple[int, int, int, int] | None, + *, + width_ratio: float = 0.8, + height_ratio: float = 0.8, +) -> None: + if geometry is not None: + window.setGeometry(*geometry) + return + default_geometry = scaled_centered_geometry_for_window( + window, width_ratio=width_ratio, height_ratio=height_ratio + ) + if default_geometry is not None: + window.setGeometry(*default_geometry) + else: + window.resize(window.minimumSizeHint()) + + +def main_app_size_for_screen(available: "QRect") -> tuple[int, int]: + height = int(available.height() * 0.9) + width = int(height * (16 / 9)) + if width > available.width() * 0.9: + width = int(available.width() * 0.9) + height = int(width / (16 / 9)) + return width, height + + +def apply_centered_size( + window: QWidget, width: int, height: int, *, available: "QRect" | None = None +) -> None: + if available is None: + available = available_screen_geometry(widget=window) + if available is None: + window.resize(width, height) + return + window.setGeometry(*centered_geometry(available, width, height)) diff --git a/tests/unit_tests/test_screen_utils.py b/tests/unit_tests/test_screen_utils.py new file mode 100644 index 000000000..32d469b3b --- /dev/null +++ b/tests/unit_tests/test_screen_utils.py @@ -0,0 +1,38 @@ +from qtpy.QtCore import QRect +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.screen_utils import ( + apply_centered_size, + centered_geometry, + main_app_size_for_screen, +) + + +def test_centered_geometry_returns_expected_tuple(): + available = QRect(100, 50, 800, 600) + result = centered_geometry(available, 400, 300) + assert result == (300, 200, 400, 300) + + +def test_main_app_size_for_screen_respects_16_9_and_screen_caps(): + available = QRect(0, 0, 1920, 1080) + width, height = main_app_size_for_screen(available) + assert (width, height) == (1728, 972) + + narrow = QRect(0, 0, 1000, 800) + width, height = main_app_size_for_screen(narrow) + assert (width, height) == (900, 506) + + +def test_apply_centered_size_uses_provided_geometry(qtbot): + widget = QWidget() + qtbot.addWidget(widget) + + available = QRect(10, 20, 600, 400) + apply_centered_size(widget, 200, 100, available=available) + + geometry = widget.geometry() + assert geometry.x() == 210 + assert geometry.y() == 170 + assert geometry.width() == 200 + assert geometry.height() == 100 From 1534118f21534e812067785162c1d35fd0d6526c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 15 Dec 2025 23:46:15 +0100 Subject: [PATCH 148/155] fix(colors): more benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping from GradientWidget from pg --- bec_widgets/utils/colors.py | 115 ++++++++++++++++-- bec_widgets/widgets/plots/image/image_base.py | 57 +++++++-- bec_widgets/widgets/plots/image/image_item.py | 3 +- tests/unit_tests/test_color_utils.py | 39 ++++++ tests/unit_tests/test_image_layer.py | 1 - 5 files changed, 193 insertions(+), 22 deletions(-) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 789338fce..653fe56bd 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from functools import lru_cache from typing import Literal import numpy as np @@ -9,6 +10,7 @@ from bec_qthemes import apply_theme as apply_theme_global from bec_qthemes._theme import AccentColors from pydantic_core import PydanticCustomError +from pyqtgraph.graphicsItems.GradientEditorItem import Gradients from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication @@ -57,6 +59,96 @@ def apply_theme(theme: Literal["dark", "light"]): class Colors: + @staticmethod + def list_available_colormaps() -> list[str]: + """ + List colormap names available via the pyqtgraph colormap registry. + + Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus). + """ + + def _list(source: str | None = None) -> list[str]: + try: + return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source) + except Exception: # pragma: no cover - backend may be missing + return [] + + return [*_list(None), *_list("matplotlib"), *_list("colorcet")] + + @staticmethod + def list_available_gradient_presets() -> list[str]: + """ + List `GradientEditorItem` preset names (HistogramLUT right-click menu entries). + """ + from pyqtgraph.graphicsItems.GradientEditorItem import Gradients + + return list(Gradients.keys()) + + @staticmethod + def canonical_colormap_name(color_map: str) -> str: + """ + Return an available colormap/preset name if a case-insensitive match exists. + """ + requested = (color_map or "").strip() + if not requested: + return requested + + registry = Colors.list_available_colormaps() + presets = Colors.list_available_gradient_presets() + available = set(registry) | set(presets) + + if requested in available: + return requested + + # Case-insensitive match. + requested_lc = requested.casefold() + + for name in available: + if name.casefold() == requested_lc: + return name + + return requested + + @staticmethod + def get_colormap(color_map: str) -> pg.ColorMap: + """ + Resolve a string into a `pg.ColorMap` using either: + - the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or + - `GradientEditorItem` presets (HistogramLUT right-click menu). + """ + name = Colors.canonical_colormap_name(color_map) + if not name: + raise ValueError("Empty colormap name") + + return Colors._get_colormap_cached(name) + + @staticmethod + @lru_cache(maxsize=256) + def _get_colormap_cached(name: str) -> pg.ColorMap: + # 1) Registry/backends + try: + cmap = pg.colormap.get(name) + if cmap is not None: + return cmap + except Exception: + pass + for source in ("matplotlib", "colorcet"): + try: + cmap = pg.colormap.get(name, source=source) + if cmap is not None: + return cmap + except Exception: + continue + + # 2) Presets -> ColorMap + + if name not in Gradients: + raise KeyError(f"Colormap '{name}' not found") + + ge = pg.GradientEditorItem() + ge.loadPreset(name) + + return ge.colorMap() @staticmethod def golden_ratio(num: int) -> list: @@ -138,7 +230,7 @@ def evenly_spaced_colors( if theme_offset < 0 or theme_offset > 1: raise ValueError("theme_offset must be between 0 and 1") - cmap = pg.colormap.get(colormap) + cmap = Colors.get_colormap(colormap) min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset) # Generate positions that are evenly spaced within the acceptable range @@ -186,7 +278,7 @@ def golden_angle_color( ValueError: If theme_offset is not between 0 and 1. """ - cmap = pg.colormap.get(colormap) + cmap = Colors.get_colormap(colormap) phi = (1 + np.sqrt(5)) / 2 # Golden ratio golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125 @@ -452,21 +544,24 @@ def validate_color_map(color_map: str, return_error: bool = True) -> str | bool: Raises: PydanticCustomError: If colormap is invalid. """ - available_pg_maps = pg.colormap.listMaps() - available_mpl_maps = pg.colormap.listMaps("matplotlib") - available_mpl_colorcet = pg.colormap.listMaps("colorcet") - - available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet - if color_map not in available_colormaps: + normalized = Colors.canonical_colormap_name(color_map) + try: + Colors.get_colormap(normalized) + except Exception as ext: + logger.warning(f"Colormap validation error: {ext}") if return_error: + available_colormaps = sorted( + set(Colors.list_available_colormaps()) + | set(Colors.list_available_gradient_presets()) + ) raise PydanticCustomError( "unsupported colormap", - f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.", + f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.", {"wrong_value": color_map}, ) else: return False - return color_map + return normalized @staticmethod def relative_luminance(color: QColor) -> float: diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index 1b638906e..8a0bcaae9 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -9,6 +9,7 @@ from qtpy.QtCore import QPointF, Signal, SignalInstance from qtpy.QtWidgets import QDialog, QVBoxLayout +from bec_widgets.utils import Colors from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.side_panel import SidePanel @@ -131,8 +132,9 @@ def add( image.setZValue(z_position) image.removed.connect(self._remove_destroyed_layer) - # FIXME: For now, we hard-code the default color map here. In the future, this should be configurable. - image.color_map = "plasma" + color_map = getattr(getattr(self.parent, "config", None), "color_map", None) + if color_map: + image.color_map = color_map self.layers[name] = ImageLayer(name=name, image=image, sync=sync) self.plot_item.addItem(image) @@ -249,6 +251,8 @@ class ImageBase(PlotBase): Base class for the Image widget. """ + MAX_TICKS_COLORBAR = 10 + sync_colorbar_with_autorange = Signal() image_updated = Signal() layer_added = Signal(str) @@ -460,18 +464,20 @@ def disable_autorange(): self.setProperty("autorange", False) if style == "simple": - self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map) + cmap = Colors.get_colormap(self.config.color_map) + self._color_bar = pg.ColorBarItem(colorMap=cmap) self._color_bar.setImageItem(self.layer_manager["main"].image) self._color_bar.sigLevelsChangeFinished.connect(disable_autorange) + self.config.color_bar = "simple" elif style == "full": self._color_bar = pg.HistogramLUTItem() self._color_bar.setImageItem(self.layer_manager["main"].image) - self._color_bar.gradient.loadPreset(self.config.color_map) + self.config.color_bar = "full" + self._apply_colormap_to_colorbar(self.config.color_map) self._color_bar.sigLevelsChanged.connect(disable_autorange) self.plot_widget.addItem(self._color_bar, row=0, col=1) - self.config.color_bar = style else: if self._color_bar: self.plot_widget.removeItem(self._color_bar) @@ -484,6 +490,37 @@ def disable_autorange(): if vrange: # should be at the end to disable the autorange if defined self.v_range = vrange + def _apply_colormap_to_colorbar(self, color_map: str) -> None: + if not self._color_bar: + return + + cmap = Colors.get_colormap(color_map) + + if self.config.color_bar == "simple": + self._color_bar.setColorMap(cmap) + return + + if self.config.color_bar != "full": + return + + gradient = getattr(self._color_bar, "gradient", None) + if gradient is None: + return + + positions = np.linspace(0.0, 1.0, self.MAX_TICKS_COLORBAR) + colors = cmap.map(positions, mode="byte") + + colors = np.asarray(colors) + if colors.ndim != 2: + return + if colors.shape[1] == 3: # add alpha + alpha = np.full((colors.shape[0], 1), 255, dtype=colors.dtype) + colors = np.concatenate([colors, alpha], axis=1) + + ticks = [(float(p), tuple(int(x) for x in c)) for p, c in zip(positions, colors)] + state = {"mode": "rgb", "ticks": ticks} + gradient.restoreState(state) + ################################################################################ # Static rois with roi manager @@ -754,11 +791,11 @@ def color_map(self, value: str): layer.image.color_map = value if self._color_bar: - if self.config.color_bar == "simple": - self._color_bar.setColorMap(value) - elif self.config.color_bar == "full": - self._color_bar.gradient.loadPreset(value) - except ValidationError: + self._apply_colormap_to_colorbar(self.config.color_map) + except ValidationError as exc: + logger.warning( + f"Colormap '{value}' is not available; keeping '{self.config.color_map}'. {exc}" + ) return @SafeProperty("QPointF") diff --git a/bec_widgets/widgets/plots/image/image_item.py b/bec_widgets/widgets/plots/image/image_item.py index df1825384..6f24ca3b1 100644 --- a/bec_widgets/widgets/plots/image/image_item.py +++ b/bec_widgets/widgets/plots/image/image_item.py @@ -119,7 +119,8 @@ def color_map(self, value: str): """Set a new color map.""" try: self.config.color_map = value - self.setColorMap(value) + cmap = Colors.get_colormap(self.config.color_map) + self.setColorMap(cmap) except ValidationError: logger.error(f"Invalid colormap '{value}' provided.") diff --git a/tests/unit_tests/test_color_utils.py b/tests/unit_tests/test_color_utils.py index ac2ef246e..39c46473e 100644 --- a/tests/unit_tests/test_color_utils.py +++ b/tests/unit_tests/test_color_utils.py @@ -82,6 +82,45 @@ def test_rgba_to_hex(): assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF" +def test_canonical_colormap_name_case_insensitive(): + available = Colors.list_available_colormaps() + presets = Colors.list_available_gradient_presets() + if not available and not presets: + pytest.skip("No colormaps or presets available to test canonical mapping.") + + name = (available or presets)[0] + requested = name.swapcase() + assert Colors.canonical_colormap_name(requested) == name + + +def test_validate_color_map_returns_canonical_name(): + available = Colors.list_available_colormaps() + presets = Colors.list_available_gradient_presets() + if not available and not presets: + pytest.skip("No colormaps or presets available to test validation.") + + name = (available or presets)[0] + requested = name.swapcase() + assert Colors.validate_color_map(requested) == name + + +def test_get_colormap_uses_gradient_preset_fallback(monkeypatch): + presets = Colors.list_available_gradient_presets() + if not presets: + pytest.skip("No gradient presets available to test fallback.") + + preset = presets[0] + Colors._get_colormap_cached.cache_clear() + + def _raise(*args, **kwargs): + raise Exception("registry unavailable") + + monkeypatch.setattr(pg.colormap, "get", _raise) + + cmap = Colors._get_colormap_cached(preset) + assert isinstance(cmap, pg.ColorMap) + + @pytest.mark.parametrize("num", [10, 100, 400]) def test_evenly_spaced_colors(num): colors_qcolor = Colors.evenly_spaced_colors(colormap="magma", num=num, format="QColor") diff --git a/tests/unit_tests/test_image_layer.py b/tests/unit_tests/test_image_layer.py index 078ffa0af..c5463b4b3 100644 --- a/tests/unit_tests/test_image_layer.py +++ b/tests/unit_tests/test_image_layer.py @@ -4,7 +4,6 @@ import pytest from bec_widgets.widgets.plots.image.image_base import ImageLayerManager -from bec_widgets.widgets.plots.image.image_item import ImageItem @pytest.fixture() From 007a408e1a52e208af960eb3086f8ec03a58bed1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 2 Feb 2026 14:04:08 +0100 Subject: [PATCH 149/155] fix: removal of old BECDock import --- bec_widgets/utils/bec_connector.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 81032e270..0551aa6ff 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -12,7 +12,7 @@ from bec_lib.logger import bec_logger from bec_lib.utils.import_utils import lazy_import_from from pydantic import BaseModel, Field, field_validator -from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal +from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal from qtpy.QtWidgets import QApplication from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -23,7 +23,6 @@ if TYPE_CHECKING: # pragma: no cover from bec_widgets.utils.bec_dispatcher import BECDispatcher - from bec_widgets.widgets.containers.dock import BECDock else: BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",)) From 1e3661c3186681a250f7dc56190882f96a48ecb2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 2 Feb 2026 11:03:54 +0100 Subject: [PATCH 150/155] fix(main_app): the dock area view implemented as a viewBase --- bec_widgets/applications/main_app.py | 11 +++++---- .../views/dock_area_view/__init__.py | 0 .../views/dock_area_view/dock_area_view.py | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 bec_widgets/applications/views/dock_area_view/__init__.py create mode 100644 bec_widgets/applications/views/dock_area_view/dock_area_view.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 163132dd6..54daae0bd 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -5,6 +5,7 @@ from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem from bec_widgets.applications.views.developer_view.developer_view import DeveloperView from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.screen_utils import ( @@ -12,7 +13,6 @@ available_screen_geometry, main_app_size_for_screen, ) -from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow @@ -50,13 +50,16 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") - self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False) - self.ads.setObjectName("MainWorkspace") + self.dock_area = DockAreaView(self) self.device_manager = DeviceManagerView(self) self.developer_view = DeveloperView(self) self.add_view( - icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" + icon="widgets", + title="Dock Area", + id="dock_area", + widget=self.dock_area, + mini_text="Docks", ) self.add_view( icon="display_settings", diff --git a/bec_widgets/applications/views/dock_area_view/__init__.py b/bec_widgets/applications/views/dock_area_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/dock_area_view/dock_area_view.py b/bec_widgets/applications/views/dock_area_view/dock_area_view.py new file mode 100644 index 000000000..322a60738 --- /dev/null +++ b/bec_widgets/applications/views/dock_area_view/dock_area_view.py @@ -0,0 +1,24 @@ +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.views.view import ViewBase +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + + +class DockAreaView(ViewBase): + """ + Modular dock area view for arranging and managing multiple dockable widgets. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.dock_area = BECDockArea( + self, profile_namespace="bec", auto_profile_namespace=False, object_name="DockArea" + ) + self.set_content(self.dock_area) From c998e3ec486eeec68d7edb879026241539b7a806 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 19 Feb 2026 13:53:57 +0100 Subject: [PATCH 151/155] feat(bec-login): Add login widget in material design style --- bec_widgets/utils/bec_login.py | 91 ++++++++++++++++++++++++ tests/unit_tests/test_utils_bec_login.py | 52 ++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 bec_widgets/utils/bec_login.py create mode 100644 tests/unit_tests/test_utils_bec_login.py diff --git a/bec_widgets/utils/bec_login.py b/bec_widgets/utils/bec_login.py new file mode 100644 index 000000000..6f75b51c5 --- /dev/null +++ b/bec_widgets/utils/bec_login.py @@ -0,0 +1,91 @@ +""" +Login dialog for user authentication. +The Login Widget is styled in a Material Design style and emits +the entered credentials through a signal for further processing. +""" + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget + + +class BECLogin(QWidget): + """Login dialog for user authentication in Material Design style.""" + + credentials_entered = Signal(str, str) + + def __init__(self, parent=None): + super().__init__(parent=parent) + # Only displayed if this widget as standalone widget, and not embedded in another widget + self.setWindowTitle("Login") + + title = QLabel("Sign in", parent=self) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setStyleSheet( + """ + #QLabel + { + font-size: 18px; + font-weight: 600; + } + """ + ) + + self.username = QLineEdit(parent=self) + self.username.setPlaceholderText("Username") + + self.password = QLineEdit(parent=self) + self.password.setPlaceholderText("Password") + self.password.setEchoMode(QLineEdit.EchoMode.Password) + + self.ok_btn = QPushButton("Sign in", parent=self) + self.ok_btn.setDefault(True) + self.ok_btn.clicked.connect(self._emit_credentials) + # If the user presses Enter in the password field, trigger the OK button click + self.password.returnPressed.connect(self.ok_btn.click) + + # Build Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(32, 32, 32, 32) + layout.setSpacing(16) + + layout.addWidget(title) + layout.addSpacing(8) + layout.addWidget(self.username) + layout.addWidget(self.password) + layout.addSpacing(12) + layout.addWidget(self.ok_btn) + + self.username.setFocus() + + self.setStyleSheet( + """ + QLineEdit { + padding: 8px; + } + """ + ) + + def _clear_password(self): + """Clear the password field.""" + self.password.clear() + + def _emit_credentials(self): + """Emit credentials and clear the password field.""" + self.credentials_entered.emit(self.username.text().strip(), self.password.text()) + self._clear_password() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + apply_theme("light") + + dialog = BECLogin() + + dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}")) + dialog.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_utils_bec_login.py b/tests/unit_tests/test_utils_bec_login.py new file mode 100644 index 000000000..44fb59dea --- /dev/null +++ b/tests/unit_tests/test_utils_bec_login.py @@ -0,0 +1,52 @@ +"""Test the BEC Login widget""" + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLineEdit + +from bec_widgets.utils.bec_login import BECLogin + + +@pytest.fixture +def login_dialog(qtbot): + """Fixture to create a BECLogin instance.""" + dialog = BECLogin() + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) # Ensure the dialog is fully shown before running tests + return dialog + + +def test_utils_login_dialog_initialization(login_dialog, qtbot): + """Test that the BECLogin initializes correctly.""" + assert login_dialog.windowTitle() == "Login" + assert login_dialog.username.placeholderText() == "Username" + assert login_dialog.password.placeholderText() == "Password" + assert login_dialog.password.echoMode() == QLineEdit.EchoMode.Password + assert login_dialog.ok_btn.text() == "Sign in" + + # Initially, this should be empty + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton) + assert blocker.args == ["", ""] + + +def test_utils_login_dialog_emit_credentials(login_dialog, qtbot): + """Test that the BECLogin emits credentials correctly.""" + test_username = "testuser " + test_password = "testpass" + + login_dialog.username.setText(test_username) + login_dialog.password.setText(test_password) + + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton) + + assert blocker.args == [test_username.strip(), test_password] + assert login_dialog.password.text() == "" # Password should be cleared after emitting + + login_dialog.password.setText(test_password) + with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker: + qtbot.keyClick(login_dialog.password, Qt.Key.Key_Return) + + assert blocker.args == [test_username.strip(), test_password] + assert login_dialog.password.text() == "" # Password should be cleared after emitting From 7de228a412e9ea3513a52cc7d7f8c70faeb4a661 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 20 Feb 2026 14:11:32 +0100 Subject: [PATCH 152/155] fix(becconnector): sanitize the setObjectName from qobject inheritance --- bec_widgets/utils/bec_connector.py | 2 ++ tests/unit_tests/test_bec_connector.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 0551aa6ff..4b45662cd 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -272,6 +272,8 @@ def setObjectName(self, name: str) -> None: Args: name (str): The new object name. """ + # sanitize before setting to avoid issues with Qt object names and RPC namespaces + name = sanitize_namespace(name) super().setObjectName(name) self.object_name = name if self.rpc_register.object_is_registered(self): diff --git a/tests/unit_tests/test_bec_connector.py b/tests/unit_tests/test_bec_connector.py index 4610fbc3c..55773232b 100644 --- a/tests/unit_tests/test_bec_connector.py +++ b/tests/unit_tests/test_bec_connector.py @@ -39,6 +39,18 @@ def test_bec_connector_set_gui_id(bec_connector): assert bec_connector.config.gui_id == "test_gui_id" +def test_bec_connector_sanitize_names(mocked_client): + class MyWidget(BECConnector, QWidget): + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) + + widget = MyWidget(client=mocked_client) + widget.setObjectName("Test Name With Spaces") + assert widget.objectName() == "Test_Name_With_Spaces" + widget.setObjectName("Test@Name#With$Special%Characters!") + assert widget.objectName() == "Test_Name_With_Special_Characters_" + + def test_bec_connector_change_config(bec_connector): bec_connector.on_config_update({"gui_id": "test_gui_id"}) assert bec_connector.config.gui_id == "test_gui_id" From 688391079776a8a268eb72458df603c8a305d1cb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 17 Feb 2026 11:02:08 +0100 Subject: [PATCH 153/155] fix(cli): RPC API from any folder --- bec_widgets/cli/generate_cli.py | 3 +- bec_widgets/cli/rpc/rpc_widget_handler.py | 3 +- bec_widgets/utils/plugin_utils.py | 57 +++++++++++++++-------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/bec_widgets/cli/generate_cli.py b/bec_widgets/cli/generate_cli.py index bbb323ff0..aeea572c9 100644 --- a/bec_widgets/cli/generate_cli.py +++ b/bec_widgets/cli/generate_cli.py @@ -291,7 +291,8 @@ def main(): client_path = module_dir / client_subdir / "client.py" - rpc_classes = get_custom_classes(module_name) + packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",) + rpc_classes = get_custom_classes(module_name, packages=packages) logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}") generator = ClientGenerator(base=module_name == "bec_widgets") diff --git a/bec_widgets/cli/rpc/rpc_widget_handler.py b/bec_widgets/cli/rpc/rpc_widget_handler.py index b261d1b19..83d9a04d4 100644 --- a/bec_widgets/cli/rpc/rpc_widget_handler.py +++ b/bec_widgets/cli/rpc/rpc_widget_handler.py @@ -32,7 +32,8 @@ def update_available_widgets(self): None """ self._widget_classes = ( - get_custom_classes("bec_widgets") + get_all_plugin_widgets() + get_custom_classes("bec_widgets", packages=("widgets", "applications")) + + get_all_plugin_widgets() ).as_dict(IGNORE_WIDGETS) def create_widget(self, widget_type, **kwargs) -> BECWidget: diff --git a/bec_widgets/utils/plugin_utils.py b/bec_widgets/utils/plugin_utils.py index 1150b3dad..32ac9c9d1 100644 --- a/bec_widgets/utils/plugin_utils.py +++ b/bec_widgets/utils/plugin_utils.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Iterable from bec_lib.plugin_helper import _get_available_plugins -from qtpy.QtWidgets import QGraphicsWidget, QWidget +from qtpy.QtWidgets import QWidget from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_widget import BECWidget @@ -166,18 +166,17 @@ def classes(self): return [info.obj for info in self.collection] -def get_custom_classes(repo_name: str) -> BECClassContainer: - """ - Get all RPC-enabled classes in the specified repository. - - Args: - repo_name(str): The name of the repository. - - Returns: - dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes. - """ +def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer: + """Collect classes from a package subtree (for example ``widgets`` or ``applications``).""" collection = BECClassContainer() - anchor_module = importlib.import_module(f"{repo_name}.widgets") + try: + anchor_module = importlib.import_module(f"{repo_name}.{package}") + except ModuleNotFoundError as exc: + # Some plugin repositories expose only one subtree. Skip gracefully if it does not exist. + if exc.name == f"{repo_name}.{package}": + return collection + raise + directory = os.path.dirname(anchor_module.__file__) for root, _, files in sorted(os.walk(directory)): for file in files: @@ -185,13 +184,13 @@ def get_custom_classes(repo_name: str) -> BECClassContainer: continue path = os.path.join(root, file) - subs = os.path.dirname(os.path.relpath(path, directory)).split("/") - if len(subs) == 1 and not subs[0]: + rel_dir = os.path.dirname(os.path.relpath(path, directory)) + if rel_dir in ("", "."): module_name = file.split(".")[0] else: - module_name = ".".join(subs + [file.split(".")[0]]) + module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]]) - module = importlib.import_module(f"{repo_name}.widgets.{module_name}") + module = importlib.import_module(f"{repo_name}.{package}.{module_name}") for name in dir(module): obj = getattr(module, name) @@ -203,12 +202,30 @@ def get_custom_classes(repo_name: str) -> BECClassContainer: class_info.is_connector = True if issubclass(obj, QWidget) or issubclass(obj, BECWidget): class_info.is_widget = True - if len(subs) == 1 and ( - issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget) - ): - class_info.is_top_level = True if hasattr(obj, "PLUGIN") and obj.PLUGIN: class_info.is_plugin = True collection.add_class(class_info) + return collection + +def get_custom_classes( + repo_name: str, packages: tuple[str, ...] | None = None +) -> BECClassContainer: + """ + Get all relevant classes for RPC/CLI in the specified repository. + + By default, discovery is limited to ``.widgets`` for backward compatibility. + Additional package subtrees (for example ``applications``) can be included explicitly. + + Args: + repo_name(str): The name of the repository. + packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility. + + Returns: + BECClassContainer: Container with collected class information. + """ + selected_packages = packages or ("widgets",) + collection = BECClassContainer() + for package in selected_packages: + collection += _collect_classes_from_package(repo_name, package) return collection From 402c72127936a0e09e0634d6288ea684bfabcc2d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 20 Feb 2026 13:02:41 +0100 Subject: [PATCH 154/155] fix(rpc): rpc flags adjustment for MainApp and DeveloperWidget --- bec_widgets/applications/launch_window.py | 1 + bec_widgets/applications/main_app.py | 2 ++ .../views/developer_view/developer_widget.py | 2 ++ bec_widgets/cli/client.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 03a7bea22..224c3d2d0 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -189,6 +189,7 @@ def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): class LaunchWindow(BECMainWindow): RPC = True + PLUGIN = False TILE_SIZE = (250, 300) DEFAULT_LAUNCH_SIZE = (800, 600) USER_ACCESS = ["show_launcher", "hide_launcher"] diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 54daae0bd..a80fea651 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -17,6 +17,8 @@ class BECMainApp(BECMainWindow): + RPC = False + PLUGIN = False def __init__( self, diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 96b6741ff..a737b1453 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -79,6 +79,8 @@ def replace_python_examples(match): class DeveloperWidget(DockAreaWidget): + RPC = False + PLUGIN = False def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, variant="compact", **kwargs) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 5ed4e988b..cf6eb1726 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2848,6 +2848,20 @@ def get_data(self) -> "np.ndarray": """ +class LaunchWindow(RPCBase): + @rpc_call + def show_launcher(self): + """ + Show the launcher window. + """ + + @rpc_call + def hide_launcher(self): + """ + Hide the launcher window. + """ + + class LogPanel(RPCBase): """Displays a log panel""" From de93becbd949b6151c6077a044e31d401672a268 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 21 Feb 2026 13:39:43 +0100 Subject: [PATCH 155/155] fix(toggle): move toggle to theme colors --- bec_widgets/widgets/utility/toggle/toggle.py | 35 +++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/bec_widgets/widgets/utility/toggle/toggle.py b/bec_widgets/widgets/utility/toggle/toggle.py index c69ee48ba..2ec60d203 100644 --- a/bec_widgets/widgets/utility/toggle/toggle.py +++ b/bec_widgets/widgets/utility/toggle/toggle.py @@ -4,6 +4,8 @@ from qtpy.QtGui import QColor, QPainter from qtpy.QtWidgets import QApplication, QWidget +from bec_widgets.utils.error_popups import SafeConnect, SafeSlot + class ToggleSwitch(QWidget): """ @@ -20,10 +22,10 @@ def __init__(self, parent=None, checked=True): self.setFixedSize(40, 21) self._thumb_pos = QPointF(3, 2) # Use QPointF for the thumb position - self._active_track_color = QColor(33, 150, 243) - self._active_thumb_color = QColor(255, 255, 255) - self._inactive_track_color = QColor(200, 200, 200) - self._inactive_thumb_color = QColor(255, 255, 255) + theme = getattr(QApplication.instance(), "theme", None) + if theme: + SafeConnect(self, theme.theme_changed, self._update_theme_colors) + self._update_theme_colors() self._checked = checked self._track_color = self.inactive_track_color @@ -34,6 +36,16 @@ def __init__(self, parent=None, checked=True): self._animation.setEasingCurve(QEasingCurve.Type.OutBack) self.setProperty("checked", checked) + @SafeSlot(str) + def _update_theme_colors(self, _theme: str | None = None): + theme = getattr(QApplication.instance(), "theme", None) + colors = theme.colors if theme else {} + + self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243)) + self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255)) + self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200)) + self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255)) + @Property(bool) def checked(self): """ @@ -155,7 +167,20 @@ def minimumSizeHint(self): if __name__ == "__main__": # pragma: no cover + from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget + + from bec_widgets.utils.colors import apply_theme + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + app = QApplication(sys.argv) - window = ToggleSwitch() + apply_theme("dark") + widget = QWidget() + layout = QHBoxLayout(widget) + toggle = ToggleSwitch() + dark_mode_btn = DarkModeButton() + layout.addWidget(toggle) + layout.addWidget(dark_mode_btn) + window = QWidget() + window.setLayout(layout) window.show() sys.exit(app.exec())