From b356f63b93001fe6ad22dfa25412b70edaf7c2d2 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 10 Oct 2026 23:33:44 +0100 Subject: [PATCH] Remove desktop clock and sticky-note widgets --- assets/fonts/Cantarell-ExtraBold.otf | Bin 105548 -> 0 bytes assets/fonts/Cantarell-LICENSE.txt | 111 ---- compositor/src/config.rs | 34 +- crates/ferese-config/src/desktop.rs | 406 ------------ crates/ferese-config/src/lib.rs | 119 ++-- crates/ferese-theme/src/calendar.rs | 36 - docs/configuration.md | 6 +- packaging/config.kdl | 27 - scripts/installer/inventory.json | 5 - shell/ferese-settings/src/form_controls.rs | 54 +- shell/ferese-settings/src/main.rs | 147 +--- shell/ferese-settings/src/pages.rs | 43 -- shell/ferese-settings/src/schema.rs | 389 +---------- shell/ferese-settings/src/store.rs | 36 +- shell/ferese-shell/src/clock.rs | 431 ++---------- shell/ferese-shell/src/config.rs | 35 +- shell/ferese-shell/src/desktop_widgets.rs | 738 --------------------- shell/ferese-shell/src/main.rs | 289 +------- shell/ferese-shell/src/note_store.rs | 138 ---- shell/ferese-shell/src/surfaces.rs | 111 +--- shell/ferese-shell/src/system_modal.rs | 2 +- 21 files changed, 136 insertions(+), 3021 deletions(-) delete mode 100644 assets/fonts/Cantarell-ExtraBold.otf delete mode 100644 assets/fonts/Cantarell-LICENSE.txt delete mode 100644 crates/ferese-config/src/desktop.rs delete mode 100644 shell/ferese-shell/src/desktop_widgets.rs delete mode 100644 shell/ferese-shell/src/note_store.rs diff --git a/assets/fonts/Cantarell-ExtraBold.otf b/assets/fonts/Cantarell-ExtraBold.otf deleted file mode 100644 index ecb257627408378702389ae59b29c296a8005274..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105548 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

; zw5L#gRGhSu+qO9<@GyTBFu_8BOfmvnf(0szw*VMK!B4H+vr0DBmnI_}0khpG-=bQb zg$v~6LLD1oUCWl6U_@#@y-HRvXG5lUpZT+$7*+6OO`%<}<1OUaZdAJkvvn|SL)y}< z+QZwn9XYfo0!iQE`Z&ecwiC0NBe|#e+r>aGhONR|Mb_S^j^bVk*yh0Yox6-k{}U`=uDko5odAJ5mRXz@ z7G>_5o4GJEy_$HH*CXDb4&0$~BNx=G!m-fa2F2@P%D(=bpidGhK)d^e#Jo!h!baqz zNbmgu`=_{YE zD>)q%u9u7%E{QQXp~eXKb6ZJlSNwUIJy8BUS~3QTmYCD*7XIug;e-q62`KA*B0JRu z>O2pHVotILu*X}lawX}Iq7sa!*?CpF6=`}Ffj=R?6dcjAEn>UD(I_7rMP+4+)+jp>R*6- zxq$^|B>ahtxu~h1g<4fQm+AXKrUxFSva=K?AVEq3++Ur^7c?{Zq;D4n172`wXeZd?J4*eJ*0l7=QM%u71|32`noO~N5R{-87 z`yW&W!Qre436Jp0*<2*g5%nfH@&R?^1L}cd1z3&~jK_Zf-$0KL&Bv2x#~OsG*q{Td z$!IBT?){nFCm3d*wcP462u2@OSrAH{1u;X-g3pP#!Gl|HDS!_wHjpyJJU(toMqaTI z6(?F;lJn@|Z--!jFCp?Slw>fn*_rpmjnVH0RyD$lKK% zgyq;ml^-exg0_~J6w#{w--f>vo9P zUA`7Y#a6tqIItMb>Fh6kR`ii1u*3FN)~ojObZ-Sao$k@AN9sb zaZ_XMC&a8+ziC_g&+}luik7WkQrB0=!&eb9MMWzLldbhs@t0eeKjl2HFb%8J))k(= zR#!Wc){y>EKQJ`w`{P^p3 zAYm6zFNWv={I8}a?aD^duFJTvqAe?emq-&5fpA-B;DsJcw6*+UHz$9CvC#X)@Pr;+E_iZ|F2&5=W!uROP-F4PT?GUk9L z9=n48AXx)8P;&;6PS7DspJYRfd|7+y3z29zHndZ)s zL4N5Ix?V6?`V591u9mh?EkVaWqU3(<5jLp0*Fy$uXhZ&Bx!w?~atRi-Wz-w0C{vD- zDQ=w!f8=%^KeBIhRNB%6he60$8lJU(5`6Ty&p!{FDTb5zkJMj+Q!0;QgWp~pH5GJm z3R%=jxFatbEK%Lql1R%<7_)EGA1k^A?-}>28uHsrUldbYJ8H$OikR<4hbzq8Q_FlO zGOOyWtsS&0RyD{s+5u_by6W%69_jC4*I}T4t9S?IK2P6j{lH__?5-Zkzqi&w2iKfr zbYd#_>}0GZGE-3q7o_G>s{YK`)!UC4*xrZ2CwbfM2s~jMR7hVCEPr#Ty*~!^sr*hI zRSe~8X2labA;Hre{`*5$IP}XI@b;}isRFJQYR`YbFz;I-2bDPw6z!4#(34{{18`Q6 z5!g~x@}5>ocwd?@N~3!WJm<;fJ7A$xW@e&jGqT-koitgaQ#ik;l3(j@jNg?kpCG2I zN`w2XCTIMqSW&?Av67oW3`kB^d7>mOpKjpzf@k1y(VXn>A@J(@?xe2b~|%?qB}%FJuH1i%N0@WF3OIlm>FvT3yz z9a`}Pv>jrT+pMmE)uQ<(FrY@v`zHnzlOe0ufvem*?a7>x{RWH+@iXXTtl01&ASc|# z@huqz8i;D{^6PZ4NOYv~zv122PS^P&VW8ExP^rYy^qn59X!crrE+fo8kS`K{^ZV6B zA?OkTYPf%c$C=4bt>k(s`r8lJZ!19F&#!ScmS5^%duG)>?_QVz3O7e~QS)T&CA|kpx?A9U4bt zSsjG!g}JtjAK%BYmwj!$;=uQd58IRWN^tWn29|-t#Xe2b#2!PK*1m7I^i=_dfpDV@ zKXW_rI!+dd2UCN=cmxzx!PMAG4T`xlZ5ReH1j`47VHcuxnq|8)(lYI@?!^T;YzXGf zBrS=~*Vwnc`zA#MI)IoM^EE0%v^v>|`vX@%#9C!)fYg&%H%qM0%Lht2fbwvtG5Eb3 z<3O4LcM$bpm1{GP@djoAhj*GK6d_SE^WH?2YEU2hrPM(0=yL~W!zyR5y(P;lE=*)! zdArsy=q7>ML=09(@M{L#yMwvq!5K0LCdUp9*5z__Sc6)}ynmm7o50viewhwj$rG({ z;eb@Grl#+(oRdekZ_hPQ6)>&iQhrIyd1`Qnb6_NzJdwP`JUImf_gYGt->IG**|vDV zs`>PY9zMVUEl5Cvi{O+#0=^pHO?O(a-I{q|)qeXk`^E?P`Hyfh8g%pcoC04TAzNlk z!8X4sz3L<0`OTKLYW~K|O^;Ab9!F@)Y9e9~Ya9XzJ2DF%J!nf7T1++{J$U?)DAzcR%>y?frgy-J$V9e>fLy_UHf?4{^qQS9e*Cx5j8YE zjy2W$@CB=<1q(H?W;F+NLGO;WlPk%s^>xS=QCylj%+`7i{yFH1=?mc?YTHY#Qeu%| zadyc_ak<%ZvZma@T0C=o^P!9z_A%57s2Y1S+tG&BEDtx|K zWWic=@0~C{zyZ+>CwY0qRblY7Yj$R)XW9RLs(UT=rAI6Fbu?yvRcz9Tli*4BEah&H z2y&nzDS2djOiemi7lH=PND{VI)mOrap)m(h74ibw(9a~*GjFzsDA%myfP=UQ&BfN+ zH{RLZ?SI;3wS#yVoA6Df?G*EFjJ`1G=Ili0Z{RU;XsdL9SC2_d={y{xBb`9)^7PW$ zYiXeKizHm_aF!y4>?c<8PFo7lumSRy2ufD7M%F^s$VntLW5NhZFHKFF&B{Na^2@;s z-zf99Q4ycNov5Rg{93^<4j7Qh^jGw8D=?~(%@n|#ny--f&?qR|4^}`8R4!U`t4H5-2mj~$IZ-WfnGgdbXw0crSxpu52cRICFomD;2)QZemLossgzh5~+ zQ%rc)3eNn!HM(>zZ{=VZ(|9Kiw#`?EkXiB^+~fam*MjUMxPD{*SDPOtXwiJjPxDX0 z`j)Br2WenYD*>Xz<=5x9?63l|!@{&V3z4w@HC54WShRl0x@F~A_Cp~yHD+4$%<`nG zgZo#4mQ37y!0wLl*n=D#baBc^HK1jj6V!!P4&v7UPC^FxPnOjJBwptHyiWVc`l~M6 zvWE6ZkeGD>oYBVqU#?2hCG!Sr$fYObe;L|%RW#+JZizLMHxV#wFY5m{iXo*@`CDl38^nNVMFA84~UIom|Ql zi zo?6n%v;t`)UjL62$KumE@^)0J$g%@1U*KTtFZs!f>MA0xt32B%sj+b%#ZDe^0rsg0 zl@P>Rv4r3aAfV$N#Nt4CVO^;PXKh<*hSqs;*1QkF@JHN@XHJZIzYboi`GQqhi{k?m z&7{B-sDuOxjEB&*--AnC2_6u;*uC%3<2cJJ&1Nr6ZhE{{IR7h=AdSG+fY@L;8(>toddl z)nnF3OddL}Wpy^mj!l084R$AodbCHMwRtb@4#?jzx<{|@kpugUIPzo?PJuRasN64P zd4tL!HE^3Zko6tltx|+T@FERWzWZb0_YpY(>kXp3W8JD(k= z%e+hwP@$BxV+%C8esi$DL_x2aP7Q+~GH9u#2Q@^@6tmZ3%|saVV>*hk!-ljLXSw#o zOs#S4kW;kZkW?JyMrdRUew1sY*`2Yv;3vBuNDFQ0zk2h1>cItPF>3orASdV=dQ2tT zOZ@6cWRls1Ha38>;Zqujc*16H&ljE?k>ic4n_mB))0T@*&+ko+jt(>W2Kp{pF~l&M zRj`gec6{@70uPBwXH#nmid8x$Q9l00et(nJT*HQdo!42s>B zIXT;2m&UzCYSKokWrb0$iBrt4tcCR>&9CHc&1O?AXsfDgSyi?ar-P-|tcFI{9m}RS zWMQvZ%bSvwY{PGIN@lJUAaIL%8@w$d2Q;E30vlU$q%HBFJ}aV@aNSfG<%2k-R_7P4jDa*Bj-Q#axZJKi zAFK!OrPbPZ4p$69WLISzMPSy6pK*LMz%CM7)W zaa&B{w(X!@eTppg-|8{g8*qeePbH9+OP~hBZ&NZq!B)ePFhDJa?sUd{a0U+Er6tMe z@s@xFN}N;$3u=E9O%pT=ANMY}L=szW&wyG%Igl8eCTE3MsG=|E)n93xwhwy?j%fy*@cQ zCN3!q{HE9`-BZZsCY3uw-{5K(M4g>U(VscBbn9`$m}2Y6<3>##W8X1wTYjtoYPfQ2 zkS1sCf%RwYGxJi7yA8XbaaubGby_; z7Cz1e?_OZTX>Mn% zE#JN3!KJlcU~~AUd1v-zq@fKzrgmm6;mX#Myl?ZbE!ErsP}BCIrutW!|a-NbTo*oJM0ViIR>fw zSvzYa(*T5qoUGSss6sg}&4{QK+cvFSvBmHk)-lBkieEf*9a+J@l9n%~!#I!s?dqZ1 z%GE>Hm8*wt^Q(t`E4_M%tx1h6^^Rh8>WsL93FsY*{kN$yjc(Y|C`_X}m?ZXaUiU^E zt+yw$|5-y#+0ehusc4)Et+9I`VagM7gtEzMl`S>UJl}r&%sS}cuu!(so{8Sx&&dvV z5m0@<-`JO#4)nz%TmfvF!zye4Xk^VSMg%lt)$Qmg9oBchW=%CT-8`(P^Xo+5yHQVK zwXwFnq`J84Yr4Em%%aw|SOCNyf29oToaNg#ow1Ljx^R*63}_OCF>leHj;I#zswmfi zDxM`fODul4tc7qBg;>ILizd^K_7I=5X6T+n$} z5h9A4+L58#INvq`Ii{*n zCs-zMX%G#VZej+Rm81jLvjr} zPcw~fR0KQ*xFmqp%uAux##T%y)QE@ClL>_MOG)n7YPf803bjCLIDlG8r3NYi3shsz zOV>DzqOuZVOYy^2wVLY&u};etss~dPw#+5eQ`s^#VoMzJ_Aks{x_4c9(H}1B*8it# zeGH->D}TJ*i*c~W+YJ*eVIlxGcH_65dr~O3n<=-O<0T8FP{(4J^=vo{cwGh3h~f0b z6Rnt4+)VqQ*Ip=vn*ql03B*k?Lf|{T{55(eoYqPwHDVU}C7d?Y(zeB_GE#Dl5Vk9# zf{j^JP&yF{wTnLQ@X}4VL_3HDXvIME=t`6f6PP=^Jvb3dXx5QM53KA)mP&I!TYv2kTv5_YFtE!{9Ufp zmMu0%6mXhgytKi53k;+t{rQphU*_}2o4}B#!a&#I#_&;`=b<1QaQ5>xPt`&$)QitP z73-h#Ir@yx(Lz;57fo*RnYt2$bneU#A-&Oa=n9(POdTkld5GgD-f-IR21;w?^eo}^ z8@#6SFtYxeGbUeHRpY1Gyb6B?^QGd`UURdTS)Am!!6vK<=ypY~mq4r(iwDUDv}_ny zZ6euq7WfJm4ry-;i5lqq_)XXN4z>xe4FF}KRb?iX(w1=48A5KJ6zFx?3*u8g(!k)v z>c|DCofi?nkgr7i=b0n^^LeCgz9T!ro|t7TE?5~LSaF9k#o~;tl}eUQv-NHkk4~;e zjj?5HEWxzdXj`HsD-9bcPX>z*RC;vsNr_Jl0vUTB$nyMiA{A z;oRHxA`SmZ$@jx7shS>_E;eGr!C0JMvGED36xAqd>mi9*Q~4SQ#Lm!`x|;S{ldq;U ztE8F<>+@7rU2#!cuDr}D*H&Hz<=QfhY*)_wE#Ks#mmz_enrc3^qjfWfUR!3!*;aC2 zlJ!V9)zg|X0J(u#X=n&bU?bUdYuKk?x@4!c-0a2`Z&Y__Oi6GoENyN$xOhRdceA2v zkUzcdX{gsGvVKL4prmBwU$X8S9PThLT#DOsB3Gt2vBN1;Tc5hY(&i;s*FTQmAJomR z&#=MGk{p=VTz!i*!w#RdJH7AVqg9Hfjigq>F4XXa`eQ{(n!DbxCaoZgH{@mq$80Y) zQrmd+Fway6D#9OcwH=p@Jo3s8svj>Fp@r;VQIk3w9dxheL<%^J>NRyJ@Yq%Q2u=f(D~wQP@%tWsB6dx@g)%oyeGkB+9?g3P@7+IaMd?!jIA;==D?BNFg$vF(M@jq%8HZM&1O8m1 zpo)_e=(C?de+lTzX6OgFDsTllqGY08$-`P1KUT3~eA`$G4(nPQxSS0^2{fR1T31!z;*&wlGWquRjfe$Iw;oegN%TKNrR;gmZ zQG~PvbE768B8?c9XyaaiO}Q$F6OAD}Qev`z=d{PP7bUZeD2jlbPs(CMVUYN=EJk!G zFFin@9MiggD5X+n9_2uke}nRqjI||GpCibRWW;DqGGe%X{1A%L`Ml0aAL~>~myWk= zK+!~W)2E|tEye;xVi|@bK#*8Qvs!x@0}Mp{Z-FGH0QK|nk1xO&pE4cd9aVsmF$JF% z1xv`^n#ol-BGFg|{tuK=KLK(!EX9?@2oEqxq=%VH;yp0%kp=LjR=7mgu@|UtuLmlO z^7yPmFwR#%vF$SyoOPngP$;Y;e`(T{qKGR)fn^~bmui)xs>iT2AOkDwi=n-Pku998 zGWV>-ivYaF0nHeHz~nRIyoy50QRt=-NNXrlJU3A<{%pKR z1FXeI#)ZPiLYGuB)H}TtGCp1cD*mn%_zmy~;&tV7hSN29*(WRFaCJrGR-V<|tfzL0 zn%nf+1PJRdX(;YEM%TPV*YFIe91dOc8HlSO-bL>`T2Fmo6M_Po2tlr|&_qHL@z`H| z{F15y5|3Z1k3+bG=Ox}o{tZF=4GjY*vN)(1;AH&%a6?RKmPk!IAdH6|t z@%+!|?QkT`DPBQ<3HQ|@i@c99OSq4Lh`wJsYVbSb6Fr+F ziY#l0XDOnHa3G2dH;-goI7Tum90(#`myV>oRh{PHKoAiQl6k_{kK8P7=duU1^{{jC z8z3>+b1_agz|jgIXXnph)ES=(o@OPG>{4kS)`kwsy}sfdbZ8;?m%k}-DVyYoU+N(7 zNlxxK#-Pv?T*@dOK%p8~Krb$YxS%{lemY>{vy?ijfIDc0BrfS7iJQ%Rbye-ECFrTE zXy@+Ieo~3KLx}F24e&+0M@Ue`727}=ZxxE|jNVV?7{>L4}cX9Y+)HVvri zd{xvDpPFV+A+B@9V@oL7tLEX7Z%9D3s1Ugzin$E@F7^gB;&%|9z_K(7#a=cPKJ-Wa z1Kh-&s9tw_cOp-X$zlEg%woXY0ggBH4tsa$mAH9;;pmF6&Y2OO1n_789|Xby3PQfj zUFs{C0dd14$B9LQ7OM4{UMV;a&C`o_0rr{D76GA6y?j@p<}-2bg(A)cT$Ok_#InU5 zTeOe(2&~~rL*N$Dn<~G7maca72dtBUErZV>`xW^RuV{Zdj4K&OjO}3%ufv#NtDnAp z*0L3LTQ;uUz1Ts#1RjB$DBmTEIm!ac-PLhEgT`P9H|fsNh?{Al?0XjB(57D25bhq) zWQYQ*)p800!EXb18+(X1x=6Sjd?px(W1NBu8^K+z@p3<27UE?FUUpPo-uZa7=Jx>b z0Kn`nUtig24if2Go8JfF1Av+~~5xE(|!Rt6D?KL5c2u|AV%81}Sr$_{wM2ec&ma9V)nBV()vuE>SJ zHL*8vMLGgk#QcqJSKLpP&f=v5R9or!{>n?GZdiJKu? zVGA6h?3H^q14HQMQVd|86~GYBn-R~O5zm_uk7kImJ}Tds3m)Q>$CpwLP9jSb9LuX7 z%c~yCs~(H0#gUuMTr_brIAP@GQZACWD|_b-PQlnR3cy#jfGC1f5Wy*k;1on~3P$>v zS!%2g5RLXJ+e0_0dnl(Uf>YEN(cs(L!Hd3)AZ~9fz`3+fsG#f3Euh1p3ho8{Ydt7P zA>P7h&;c4BFVRE^x%x5t@?)>rL-(b9ggg~*yI!fi#vq#RZQOr;?)CD;bDQ5`_3c^P zdbL>x!-viU4c&V<7!~OUjoP*g?va_=XPLW?IEbbL+d@VR91}LekihKMYsB|gGDr07 zJt|}{Oe$Xw6KwvnID{BC*S{>v&4>N|OFLw@ zbsWbm2e66`P3f_cy45)aCgxDM(pOM9G_ziHm3_JA#Z z#|I);MCX8hotxP)-SZk~Z41&A!G=)Y{bKb32k}O&V{scd+HKyrB=f+Pgz=Z)2EZa& zu>S6#E#v&{dXE_!*l%3+iT$f{b912m?Y0-9LlR=rOK!l@fZG3{=2oyKr0FkQxccCw z166@z-W$_ESRR*Xd-j2Cq*lwu3~jKy+>f*Y4!6fF3!7*c5i>D#w8JZR>*M*~-8^IW zb9g?p%5!kv6TJ7Roz8WGpzDS9LCbBA6}pxUZav1%*NJtcj?fx%=M5KFG&H#AeHC^? zE8Iuyf;_3^*e13LA!O77t{k=Wy}}Bp(o_Tdt(b0V_<%n44e6|&w2QVL{@z}cPwog{ z{-0ge%H3?{nr|F z!#LlDz%8X(401XkdqOpEO`g0qRiB%&c=;Yf2$i#*IVE*=s(p0gs+|Vj67G6=U7Hj= zaaw|XZ1lpl6AbX0@`R|;SsO!f)+uXO&)i^tWbdXdN-g+eZd-&9Tog&bmT(`fS zn(CeC5ob2)V@kFrXU%ykZ+(-hA3+$Sx=??v zZM_a#;giE9v3l@BcdhuSL3q!BK6btbhh9%{5T7+p?BdhLuJQRkR6fk%X55x=Tr3t7 zUd9wEbXT@r_O?Aw98f!)SguspbPnIJE9d(?xAqvQ!clAS**mSy zd7q@4nF^KB7cpwwh|O#Ae1V<{l5(#^kxSiOYS>WzMX1LY!?MTDrh~;6CN>j&_PJ$oo zu7!NZzC7UIxi#W&ft}8qPk;zmT!9%rbnOu*5eNI!JOnw4PeCv{qJi~6QwyeD!_(T- z4E{9aQuKnp%u6^nA_<(+HPV=bZD1;l(dwG-mvk>V8zv~GCAdao zUfw0o=jfZeCGoTx7X?wPwpXG4YuKR$YvyGJb)YRWqhlydN~N zHSWR@(rbnT?hf~)ky@&^2!_KiV57to7CD-#+8@?$*tTgyYzGJNu5;k%K{G)67rgS= z7T+BwWch=;iLA=Z`+~QD` zBN$0FSS3D*Y;a@x@8tOw}NZQz=|zB9-0{Pr5^vVMFD!FhV@*@c&Wv9&k}y-TU|!W*294Mb<&xb#_5iiWSA)P*Jfr z>pP-u$1t3u^M_{r*1x zm>FhgZolW=d+xdCJO`RwU0oq71{Mh>bHfhO`ELQ^Xo_4ofu{|U#j8w)5}nMWGmgml z4f#ghnUN+52UtZLFrU<2zTzd}B};HC4*D7Fcs(o;Er^+?f%2J8sU-6lET?7fhWvjO zIfe{u$p2&c{8i3}kkZq}V-00%z)_YtCTdc6tRe3~6F{su&HX%E7O~m#WYII)Y4Qwi zT%W-Bfkq%23RD`ixDc zMUKf7C0Y+j>lbUfeqVxo5uCM`O%V0F2e2~79*|zi7qM}xL9J!)Y9ElOn|yJh^pg$B zc8+k==KxSjE(Y)~Kfi1EkJQs?dHG^*{<@9-6syVsc$m1@Rw^!gmlQ0K?-&GRCrp2m zepeY^<0w-m%JexzEDN!-jA3z$wW~efaUKh@Ev|?3eg zv#2hJ3W>$vN^}J!LJeB8**O`k{~bFFQCZOgSlI(m_*((Zd1PBywiUHYLhZfv&!}Y; z`=3!S7nY4e#%z-Q$IxtLlUZ3YO2Lqr6#Z%}t368u@%nT3{|r)itgnP1w3V&I9;UQG8oRJ;Tv#?P z$fmKoy+8H+`p{;!596SS-EaA7?7Z2gupiqLq5!Nt7D)E4W#L-ep8k6{*5?bPTUQn$ z-#&B->BdU$=xs!2{mb4*I^MHxULdiKWiL}mU7iKD_b&po9%b+6v-hNN86h?fWQ7kz z;r-8j>dnT1Xv#(x(IxiY)c3DkR+auWQI%CSQI-BRSyhlwY2W@I2`wq>Tb591UH_R- zO`dw#iS1!?i8Nu*yy(9mR<k`A;{Fs^oAvi*1@dME@h-CZUveN3w1f2^ zhm1S%XUH~^7edOktV)1ebsd6wE01?g6r})_# zv!9SF(GGI+Fdn~AB$&PBN^Rxl=uy1rjNeKGXf8KLhq4e#yzwbb_de1^uYz$4mc5zK zEJiA0;;>2COscFQ6@YNLMy`(B@dgA`z}`G373AtDL8AnIDXFrBr3Pm4ekc3tgO`&? zmD6P}bM31TUgDICnp7KReUj*W~{X3JMqi#C!g6n4-vHmqu{~pes7@$>|%LOpb47#&4h??X;)X=B|>P;`E zJ)C+MmYACKjyt?M8#+%788jgFz(o_mW`OS2(waX*AJ;TXJ{H6{a>hv^4OqyC_N+?& z|0V=Reh}libfuhp6zS}VVmpJO_dwBEolEF6#?9Yj`7s*Lz_-Sw%zh)r?6>(FvyV{N zCorzY?~T{Q_#Xlk$tCtPi&jOc6?7T z-2sjkyBqy$d@h+n!a!Oq)^bv1$mb8Y>2eYlhTX+hDTGv6_~nNNBn;%vzHA^>;Is5o zT39Z^K=4BO%d24v5jzUg^K(;h>Md$3UslD(N;XMZut^GG`nW|z!&As$XY9&r?}o0i1co5KJv{y197f*gZi{=VHwlT zbP$Q|xqfPGzVYIbDPxCpok*R`utGp|F@Uy95PHM1}3; z?EtO}ua$S8>+Z(db0I5j5GnT)*pn*t?$K89vlcC!4beN#!N}Uv)=Cp+tIe%Ca(+d& z@%@$XK7)ouwdvV;)AkYOk+902aP3~)w?6NfAmDV#J>lgn6k8-92V znUdJ-3DtG(H+AF~GjUyY>BdLn(QjtW%rPG(F5J$+K^uS(IhmHD)uEUe^A6es9n3Yz zc5&~ngIf<9zd1Or|A6qY0lmf?f1H4B^^^yo*Vf9Z>Kk4n!~fVL|HQqsrGj2=LcuhN z807w>8Yrm=_>v|NWjBElCs1(6Srn%YQT;$%UiCcMKGUqff9pW*nIc0;?HjnhEnP_q_HWCoEE>IdfO6g2pv16Yv9nV!rY8q`D<|Ga)Ri^ zzFS9b&oks??>cijCn_Xl{N#~ICS6A~wgIhC4Xq+6Gf|Fk;J=eutg$Bp5577{CNZ@E zvelmYdH4crL;nbtW5s9F^ADS8rMbO2xf@6JS+_aV970{Vsi&?b+%m%athX2u6>1q{ z>^COo?CAs9r!VFXX|}~2vEpf|N$D46j6vw5; zET0@9o7a*m@v z(;8YGLOLcE*eV36Z((8yU=xe{X=2%oi6wwdEEl0Y+|{Ii6x+PUfD12Rll^)D+Ts>WiRHH z`zTo1ypr`_^UB5dNNuA8lL5!Pat8BCS*u`Pq2p-``e_j1mAeyRRW_5xZ0G4S$To^` zw0(<*L1-krBlaLdGUo1l8u^PvHzJDq+^f?>y_w;L2GE$N5-2)~bU4Ced`95OkEf|L z=Hsw#wYM(Ub4Vn1{ zbnSw(F_*WbBA_lSA)84wW+TrMcwg}D-ftLvh>xtZQH7Mh)gXmcAPwbrdw?C-n#Erf z-)*0yzg~!ne2dNI#+1L+B863AVObp(+N+TAw;H6d3Z(q4{3)#XICn$o-AD=&O-^x? zehzDyNhz$vDPI;ig_W1A{9A#^N>*aBPWCUB$U4^MP)PjT=42gfbHw?w&B;nuTyh^S z-msnmg<%b8iZ&-J|5jqMk`w=VpIsp0;BQ!P#U{BN+o z8bDf}LT%);*HH@A6J+K8=n1lt)r+kBvSMT5p3qjwP}K#D%ir zZyhQtSdUu$uA{uH4P?ca{V6M0#bw2p6_*v+lC5ns-b2^g-nHM%2_dHawYhD%S!?zfv(w{a zU>+OVr6+ajZnBwtM5HATB$cEEq+D+t#?~Y}v}!SCWz)CNb6HMil9ffg)swD6+LOM&9`@Xq=e7oC4K+pw#_SsOSVXQPaGGWd(o`lzx-T!_JQp5vArQGTHZe9(`dt#V?5Q3t#3_(#gga9;Z<#7CR zCo#6oFL?6go&&1-0DBR9< ztZ=KFqi`#lqi_?NI|`?hJJ<`=0H{0)?BCYIf-QnnlKqfSZlTqRg!UHx>49TTQt9fq zMA4copcORw@r8$Z_;J1uIv7jWqiVmT{1y;P0x&1rd;B|05GSorkAuT;+ogr*arqy#w7Dh)T|g%yrLx*}L+#iI{m#}|Bq(2M zB5Wy3xGVHG@QNL^R*kJX3YUvIqubv5ZY345651Q8e*e*=6Grqj#<4YoZV~6jPfM{F zBP<)YZ`-mVBXj%oF{{lwYX}xtzgTh}7?ImC1O^f5c_KzZb*Ge+B8-Fo_@dCn3tj0X z*`6#wSL#pwvE0^>#7eemHzn6>5{@OxGfBL1xZr|WpY{wZR`0_ju9$STYP1Wvrz|3! z)oHW~tMidxGKl!Afp*r^m_fzpMl8%tiDZ)qrecbO27gXy9TFOBPYAYe*-n$;ZN;7* z7=9M|FbRiKpU(Q-|0`+zb)uJtEaAz|NhCsf8LX?`aaEo|+9ZjSCGx_p^ro}9-IT1s z481#?29dh@@bAc0M&g_0b{sJN^iv-F8C+VwY!qusY5jmaW-m)wv_9XF31XbB9Y@+~ z;t*9I(wXcLyN@2x8|~5^ZGtAcV$z3P7+ugEDg>i%O7cbcd4{?{INZy19E|O2IP7GQ zb;k^iKJ+XR_YxR#J2xq!b=wEbjOuR6#YSrL;UlZ^j8_lL0Bn1B5Oo<<&!C_313f2R z%F6?KeRfeuevcj@BfA0ox@X>~ODrP_d|UF)Ue@g-s0*?i1ns4C@*P0)X~!urY3)zN zNiZ6x&OdY`b?@BWwCjp#EUCi9jSd0aUf1zk4w&`O(sRlC*`VLugq#z5jv56W-izIi zjJ$mLNPdw?zx&dtyq>H-p`H?{=6aOVVv?@$;U4Rlv~NL@*d z5b8PR27ta4zl6~mOe1IlF>yM^uE8)5+d#Mzf>Ov|3R|KA8WFzOklQaTt&V5kzZ8D} z#``Y~bl!aR?_K0>%+h`l!{vVO{uYkO_3s0`@Y{#|e#d^7B-SPt9w>n@sgwh1QG1Zz zG)pLg$N@Fk@84YB5E(qpJ;^RJ+b`x3MwN<(Xwf4q8n-pgh=gM&{X6^M)7e?gwA?TU%|3R{!kLvMU#oFDJaMdL=16qBtkrQ41Pdau7@!-7_4SuaJ@tPs2|y_e{u&n zf|zG^v(@kk6|81vsVX>r?M82Yj&gwPMif7ME<&6?>Sz&uH$^mr{}a&=8^3h$mfD}V zE02>x^>N@XkwUCpg?QZ&`t|Nm+}os1B7H@96LGg~0uagr$Ll|q+h6~&oVfpIxdxj} z7Wjm`Kz)J*UVy+0SQ_w@3of};>kicT1OMoW+D|*ydeJ9EPuC+2)ph(BM!nrop}`E*j!5Ew1N)>7dfOX zaXAEuC-0QoDKliNS{}SpWZICvfC=;@p&ad`f$~^5hv{IF{X?Cy4;?ks4<8&+)8#(^ z-ioM@m^8!&vmP1GQP{!Op^==;pN9R{hr0NH%^=HmC;1Z~tcW{ULz_Z=QJ;)sz!p%3 z8($VA9YOXZL=O!%)Dh5ALh4Z7YDObCovm^xPPL-cBuVxa$KR43(370*mr(w+wYQoa zUBN@e>m(5*+d9GluR4_fOVNfojwmL;Xj=wgE#^Gp78Wr>*{2ZfUkhPDj6u7RyjGLu zkn9Z9&_=QzxG5?Q=Q}tsu7ILla0J>weo}8m7*w~W4ANmyrFp|eG?|LH3oKo~2+Otw<2e2Q$R zU{&76-CLaX`|MDwlg7-QV5FL!4<>R6 z+|lZz8UygMgF!!(c6^4D#OmW9o`GK75~_&>u*yza#p3#o1V&+sXo7KyDT0=V_(Y@c zM>uWHiUVu1HaqKg|1Ss#eK3?4Bi4;g9lNxeJ``s@EonWN#Md7i+>~k!`hI0_5RQt9 zvGQ2};nqWC%Q3~eAVhIvwG=elNTm&5ZTxEE!fN{T8C1*RRT`;qIQ8KAjLp@`Djja5 zoW0We(?!3QR=Ni&9hVxnG7gmv&cI4T1+381XgO}-g2fArvt}(?G#j`S+lj!@@|sBM z&(-eR9o9o$P;)-wa|BlEcAN%`rTf#@L9&Q{wGBGyHT>oX$W-se=)5pyjKFBa=?a1w6rsgf~ zw|V@Cvm7{*cpR8HK4S0CLl?iv&OUSD;F#Y1!a@Pk%bH}n3`{EuJOIENv{_92k^nz_ z5ui}4<4JXm{`ULb-`sr%k?+lJZw*-owiX>dpA>i0ln>le{l&3c2CW~m9G!zU^n;vC zf58*(>~DEnnmjuZP;K)U7WgPm5J;s zp~G^31&sVBuc<*7t_OHr(eqx1`mEni~O#rR1t+(X2(Y5)8?Ula~O3 zT<+%a7;mYRN1R#^lM!t(CKm9?4iB0zW5U5k0HB5-(vYB=AmwR~X6!t=ZCA>i4ATLo zqZ=_Ib(oRXZsbpEnxk4%8Ey2lNeu>>6Z^ktuLi~sU zDc*pvvo5UFt6^oTYa47^)(IfvwEPW-5_7MeYm)}7vSJl~nzqB|jq2E^xjcPeNTEf@K8pwkWwsRhZnuUuO zE;P_;uUnBiCVE;ni}JaWKANvrEnbysAmy7}r!4||%$_>XlmiZN(~aG%Q5Xfp-j7LDQIDn0@dIbIeU%M;g-SnjNVdHm=^~OvZmeDsbNV zwc#=uQbGr6^HTS1y=ctJpO*-gaX40K=hcpyW|_OyxT0CoiR<1LwOvadLL!j;UZPwI8JhY_htsyU3jO6aak>9l;FS?B5 ze!*PXPg}d+i3TTkbM3WlLQXcw*Z_faGiYBlxl795!x_g?H;DFUXh`{;_mA&0!49lE z`9phR(#{st)c}4I-?U@~MBTLEcGC~Ed?PJwFu5hIN@ET777YDx;{yq?C~shbYfS2@ zZ@3X3xv_@0*&1_z-w(a%McET87Y*s;aibG1G~#V(BQXMS!?hUe zt_xYO&o5@c%fN~Y*#pnrq{^!+zwbA{BF1JK!p}s%nvK*#d$X~jfuT(6c(Oo3BEwLS zVV_b7!l+-U-{(lF$ez;vci-h)J!&TE4;uZE&YGngmu%c*Sh;xRqLn87;j16&X!QH9 zjccD>3#n@`&$aP*&mqkd8euftU z=eJ^pI5WN@ZYcU&>UJYdOS-&$gbj(g(OufaOk?PiHX5?bjRa+DkMEz*Z}60HV=!fb z7DI^djkh=6Dv!vS>LVmXBwaMimM>lg;~*NskpUX)co$vHIecmDgKD?mw%2^MGkM4C ztTC-x4PzK{03Yc@bHRCJ&4esioNq?(+7BewsBmxuYeKnoJVH zfzysfgAUZhF$MM#YR1n&++a;S`{t|3#J5OH?DA2~nn13P$6XB$Cp(EVXqVZf3rFJJ z_GxC)b|}VT(#M0*{v$}|qHt=O&#DBp!x;}rBhbMI+UN#p^nq5;VmI}OfiSz%wUG6| z0cnVa44`;b!GkpFL>nOr9<*%tJnQrBdDap>V0G-?SZg=SZVMFXV}B$@*iLOC-`m2(qJm>L?~FLnEokJB z_JR4su7TE?@z>=3>T7JrOHQzaTA*{l=bf?tue=~ntNi4`L-7@V#>sJg@%1;$b&K)> z!w|3ARD4KUaS$CD={!xw8IDZAlb8LeJTVT?ocOKD@eZ^OM{mI5X1QicFh{53$>07| zjT(zJeJD8G0J4y@QbNE9>WTOPP`;Q(>I3PoK7cyg0`jvV1=1!8Xb*<6sV|U)qCAbX zvQ5L=xAF+(O?jkRZtzxw42#E!68oou?l_*=KUifkA3|yvZLnYVXQD>FTS!W5oIIf%eVZ4Iq0 zh$LV?qb2icc{-muA=Jv!Q1S-(wP*{XQGjpN%0V=NgDVHQe_y`lgGqb6}81 zsQLW}?%4KC>(h+-^nycuZy5DU=vPjTI8CWg<^=Q0(cJz` z8EKhDecFYCU7@SsAUGUqwY5_mxeTSYw&YuT&8*$AZIiF4(FhSD*5OrH9#@hUx9Akv zm!obP8fj||anwFcaVLmBcJyt?VTKRYbE0_mF*heGC*`n_1j9hF66yVN(*5Am<}(ke z`c7{@ZuO+_<>QP%&x5rx?d!k0!-;|B0T^ZZq^_8hyktqT8F|KVYp1Q85@Bf4zi(3$ zH9i#QZk@kJWnI7SEDX82n;X$i1_o_W9JW>`y4nuti(tck%{7%sL079j6)`6oj$p1 z|M7Df!~3P1NsYteft`DH9yS&ni0m^gI&5g)X(um6Ly(sWv?*_^ns`G_dx3Nu4zrN! z5G=J7vFi|@eNQxI{DwOOsM(Y=9ND-nbHnDZQr5xTtAN=oUSAtNp5gntH1?;JAY+8@ z`pR@kEkpR6_{ccR@^x{h;nWksX70^N*>BVlXRu&!62lg^AljK6eocp3Z^+57aj=am!Bn_QvyUUR-dznC} zCIZ$3_oMqo&LRyNWCYoR_P<#dhOyQPWY3=!@ORIj@Q)N<7logSFh}Kc>Z5hRz@k9U zG!RcD(@A75M}8m;ZRfDyVt1B7zoI)hT|Y7aiVE%)>qTgoH={w-!ex)xd_KUtrt#DFe(0nQ%Gp z^xDJ5AM%HKq?&g{j@;PaNUMAJpfwD?`jc8_uo0WYfVgoZ2O5V)<{UqjmHAEHzPL%7 z&AM*vm_HI;R>jziX$;J_B^aLe`Yo53EgSavStLnoNlQ=JVceIsE(2nOEvqJAc?QTJ z7R+nRAKVN}a*T08Dn|wC92I@WkO*6;6kc@7N|`WugLH-a9pUh_ketn&R%_|&1u$bq zMlFa^o}xS_$3Uhf@ z1CTw@h@|n)KLT~NcEcaBBKX&9OucEX7fne6^D0tR>^0`lrSmztXD{TA>Dec2ETj{S zoAH_QHZ_TK!|4#r;7e$40!esX4edo=aS&RK6uro>x1d`U9SSkmRfrXY2Sf!wC$ODl zXQSl-TJ3t2NVl&hFf8?Tv_L<$sNV~#kh70oOkAols zrrz#2DXl(q#A1(NDcYK(fT?Z<-fj==1Bb3VC{V1h;4d{J8R$od(KDvZo^Bi+ zbMV5IT?L2s@0mPirFpSdcPNQe2jE~;96Pn_0Ockp08PFm2e9$Iz4Jtod3Q`^^u8(2 zeyciZN96XXZM(8}9@%!BYKyB{M#Y9spET@X*#5{&=chT(@_skGYj%UWK$VNS`op>5 z*fpWMM!?QKa9pne5I|M>wJE91OqH97ag!sGCmMT?*?S^uOZuLz=A6)@qb>|`*5CI9 zSa8zz`CHQrxm(i??AsnUHEi_s;OQpad7J^eE5VjF!ZCC+ru@|jCl=NyqWm+8G_z70 z6cX!r`GEuQc2VfNSbg7R7lROqggHZR$dx3L#EjE9$q=1O#S_zv^I^t7C#Q100f zdd{$U=h~dZCz59!HDwc#+dQD(>R>EHeqAWndq?=b>E^I0mT|+OiMrubSA!)canz7r zYtsgr!r&M!=JbPE*Nyq)wK#TF-U?(;FfBBeC&dm#^CXp#|~%g%-_BzC3%ZE z4}yyg6UMF_ZKSp8G{JUR|Ew8npaEpdjXdJt`Fg>v{Wo0C-n{$ho5$B~>Q5#@uWSnd z@ZZ0|`tDu(4Xs1#*Q|}Z*E?TQ$E<&p=z#(DNQ02iH){2d6Hhhde$+%ojEcubPu=bo zQGB?40|i#!h2GxanIRF8=3c*ZJ2!3Iz29(V%)VYGM%SkjA5mT`*r(mFI(gRYxk>P> z^g_0NuOx;SM17z1yr(8V$*$skOxeYh=T2#gm_)PHJyO(G+n6@;gWu@p8Z3}S;tLZ0 z;(NNQ<#QY@#K<^@EBUCrg6#26O{U3}#WzJouU~b)T-Up6H{ZHfdi-Ku4(=m-`nJ89 zTet4rzis-&NQ)%`^zwZue+P2)J1cmd4<2#;N6xL_=@!V*ZBFc<>?ZJxZ!(WfE&iy( zHY)TlxgmLsbr`-n^dUEC59}Cl+=O%5G!pNG_3kmAb+pZzSh*v(GVw1(5}P;ICn_v} zx`22F07}JEu=9=I{>YIEp0^2sT?(HNsZNz!22f=5g1g} zl>oUfB5rDOFG(yox+d+2Ih1I*#Mr6xrWuD%*jr%EhE^)6bLz<(15tIqQJYq1Q=9sk z=zi_AsnJnU5yf4qu358o&FVGIy|eF({n0o;ZYI9aUR$5Pb<5hRVh*wRmj1 zn$eLo0xBcbNSKMvk)3ctqkUX(_XyD#iRMm7vjQ{i1V3jNAVc7o@5=tHTbq+&(&`=N zcsQ}tza^`H3HR9A4LkwaDgDukJ}C8RX~wrN+*+FSj~$*ZO{JbK@x9EsCB8BLmY5V; z!#@J{pbW~?6KtBNKKrJdwJ(E&PG#Q0vS|eyw2!A?alpREW?D>6DtEDCA3TWEL(>Mw zKG6Ku*?QWKwoZegT4X=kvJI4rNK@rz$PqV_2#H38^6Np%>v1wPkR1svlkb3l_7aLQ%dBk!5W`wH_7Ynw?IkPvVe=0rsvpFx)4ja>No%>gormruhfYkqwR ze58EZOLF{#i&pm2M@o@4vx;CHhfIN0k6-oq(hB;qDxXFL zSy^|Wtg=qtaYnN0VR)4x1lbjWa+Yd#=DLXmK zvWLlG@AA5OlIuwTT0@d&cb%J78zpKiQ6SDLPqOy&r4}!#Bd&XCA>UiP4=qJGL+{3e6= zpsgHrni<%$rJ?hw;ZHGtIC|4wwS4p@^48{SQYPraF=at3egSwcG5y=M(g%3DK zUic9(5g*h!5UUWdt?bn<0O}N5;~`JjC0gTwNfM8jRmoOmrmR%I1T#m@690Z5=|$Rv z)5&w0I>Rvaopz!u%EcXz*z~qy%$ptUgtC*>yAq?D(ib=Y1L-WadmZkWA`w?cJ%{a{ zh1t)l?B@hVL9`?5m;H=}T;H+SjBYN0Zb%h}GvkhS(z(D$XFMYv1j#|yos`XLvO>;* z(I!GLQX=K;r1Q0$w*={sW-Ku_9&dn1ZrF(g=i{GxVuigYru{#9f_8-G=;g7l;G%ue z6PrJE%Iu%Gd9&gc%r!>Gr=@S;&HwBn>zq%c$vVeNvj2p8FEy!Uo#P`}qsiCG17r`Z z8m!Ubj-T`K^RhL%on*C;#mbfPGW8>?C9%{Y79y06CTk2lJX>dCG^Md739!zr%L5F> z4Py-r5^aqO;;oi1MwZpG1Szr?=@@ABk@ygdE7Fp;F8DOCtVu8`D;rqW)o3&Oz+xWJ zb=Fy54lMktkMv6mNV;1svv|29tvU+UCYl#{H9o|kR2|AaM&brqvhazI^- zr$x|^F6oQzSdH#TbBvLYYyin*K>LcMN(I>kmv@PSxdovAq2aWC?6?+qV=tp!-RZaub=SCOzc+L-BhbNmgznN$S_~zP|Xmii|3q z%Tj$%QwtE9T-V4|F$FY`_Yk-lPotVv)cITA`WPr1Nd;v(6o25?dgDk$em55r7}N2t zD*yZw+BswUq%+3iGbg@ZZLX8f#mtSL6AvNPl<`?ZjXj3+Zw~)N@KI@0o(I{Umyc$m zx7!mX=Cwl1Yv)9L+Tc%<-*z_nk;MS(rFgXhln$P9gpI^)t`|+ay)7Vv+k6EuL6G|B5 z{4d~O=tEMA$E~yUkSd2xDspRh5NfhUqG69~N#d zdv+}g$L!Nx0ScLBA=53cH{`m&qO1blJ3*cc1Q$RT0+5b2-O1^*XHj7Fz$B?OXJ*E( zV`!4G_C(pwj@BG9Te4<;9wqwa4x>HxZ8rO6`PXkB_!gw4bkBY-Mbk-U^A(oboSG6Q zO`In*wC+OO`&icUOD$qcTb7}af^*1%ZD?uJmcE(oFvb424v7EU0Wf)X%z`|oZnOa! zZ*JH>rwYui<(1pyerl4;9ClF(Cr$TBM~Hd7^~--}&LX@u`aaArK?o+#YIx4j=rEa; zMY7Iek>G<*`72yY{_1d&_#E1Zb&i0ZC{XU^Du2O3`isMX)?6&0XtED{(rS73YBF$j z)@lt+*q}9~jTu$G9Gr5I4uZ9rxL2}8Hip7dmiY!He;)LRY;)>#?;*CgR1_NaAUaLN;{-ai@d39BF4RsxLBXx6i8+9jjH+3)dNc9+Xl-i=6 ztzN8Nsa~&6Q>Ux1n120ION8sjv@ z$>KEIX};4Er!=R-P6bZ)oSr-V$|*Q!PRCW|s&UP^NnAXa!Y$+0aA{m7m&aY^in;sT zGwvP*LC0ZwK6>WWOH|-E@ly-wQLwiAcS^LV_+1b;% zjq^a~q0V8>QO+^W7U$W{3!PUx?{Lm>E_A-){J{CC^K0i1<&@sGFBxxwW^%7vGkQf^bZSGs&UuTy`mTx$E-G<(12aO3F(5N;NC_S87)&s8XLwV=Bc| zT3ji!(veCRDiv3{Q|Y%#R@d^bUalQnJG+LsPH|o6y504t>si-pu6JBtxl+Bez9O)` z%zA%)XZ;X;h(1c6r2ksKTA!xR)t}bi(*L0Uy)sw1O63}r8&?jf+_Un~%Au7fRJK%3 zsyx5);>znQ@2Y&b^4ZEaDnG9Ly0TT&h=N#E^b{M4t;HZQSPT&(#8@$2oG-2rH;KE% zgW?JCqIg6678dWqRA~w?#602_&YQnzzA<6$>K$f5BsT1sY#MuVC~a>C8;1*MXQfyfg~h}#f5v2X02Yg&n$d@ z{qWDv4ZxW*VgEkPB)IOwIYvd&Mj)MWcvsy1jY-(L!s05}F_D|HqLkAdJ(+&tf&nVR zT@$;_9Bb0eMN|cYRujMgw?i54Bb@$aS0>^6m1=y*32?5jdwNq~0|hkI6J*%Ufe<$lFMT?bCE1DNWFc#c#R21!(h zQ)FS5q_ZPBq41rAaj-sG8ZpE;YV@u=3%q`J z=K|J!H{gv?_xKHOD8(~RNCUv9Vy z4vhOB_}-G>E$L5UGc+5UA)FK>uS9RmLhhk_8psWC#xaK|2HFLXmO_mTE;2cOR-945 zBC!p!gK7efJiLjoz?^Ee6=LPt76F^Eny|wYmhvP@f?rnQA3jV#ue|O{dmsUW0G$yi zbeEtNh-SQpgB>VC2v7tBeK7B@!mNdJ=Es86JwR?Xgf1X#>YEU1aK1A>A0s2Ev?-B};&hLA-QM&SYctWVUjjr6x!J2XWMI^}k)^~< zfcOlDia|nNPVS{Nfy9B)fUU~2W&Cu~9+oKgVTb}`H3dM#f)iT+L`=~VxM2z*B@bN; zAm^Qu{$=8HN&lOI@NfmJU~`fgzZy$qKF@{;Wb8rerRd`pP+8)I2nVv!H2@nuZwvsH z=Dbh@C8TmNB&o*}gMD-cO6V~UG|c|F;{KfFYYv(xLLr}Y;HmKj?V_d5x4IC$2@PFO zJ_e1bsZ0x@KBQ6gp1vghX1L<)U>mLN9TgcI6~1cCRMSveor}pUoO8}7lzI8tBfrJQ z2w6+Ep0>`gX%X_vb}0gB1NbdA=MvN7qkNJ@7-7fL1I~y*`tv(C&y1cjGY$uwWCcxN zR@mIn#N8|>KuujpLGuY^j)_n{Urg7vi94w$kZE(pY{&QluR9aGt^i!f94?5A(hAJD z%Wl~v{H1`LPc1;N4=yoqwo+HfOx?6`gHeE(I-1l(`Kv?YxhhUBn6u`#5RrAjc}9?4 z2Il&1=6Jdzaehv!|1j!On>{cs%-yp+|D@q#qqDS~i9%Ddt*tg(`$FCI(t$}shDJvB zHB*u4SGkfyB4!#xvF4kcsL|20r$^5;>l*Pzd!_lrmPoqkOX~1ADD7(1f{YLHFzo0? zPNnfeN9ii*I)Vo;$aYRxcG~L;`~lMeJF$Yc=s$pgm4p;pcz8;Zbj1fWMWlkVDU+1?ZAe{usPbsg@gg}G!K1GZxA znJSfz8^Gh9@BiK;q!FMjy#$#5yC324N{6M6DXsloIwRcNbnF~pGCB9#v}Mz#maz7w zLsa5+UOT<^r18!{OW3GklRKJgQ0b#afUqTDSKizs#wj57-|t-dZMV7cVQxUegy^R}7~LQcNg61gJM*f)6c;PGZb z)~?LqW3fUbb*mf*UHEN9q5 zsG~#L1Sms^R)_nuYHU~ya|K29wL4lHLAnVnH)y5?Vj#4|5)4P`S^>&lK~QDwWD%~w zZVJ{^-7znwuiCht4GxFpBU&&}bMrk82peKX1QSz%Dz~&*CqNIy2yG1%4reMU7QvTi zKI6bl&}Jw=Q{aQN4XDNIM+W#x`ZkGlg8cPzNxvnahpjQTk?;%Y1H%HqvmS#MaW)Fk z3}T_<qu>jS_K#IjcI?@*K>Qm*@*a6032RIo)>ha$+mv#ULsR{6+{J(^Y zA|bY3btJ*=7Y9Baz>t6eWU5q7KM?)TJ@VUQR=9urz@f8%il}u145B}vB3#=X9(e`| z4$s<89^JnTDx#?=f)tAPV~Q;U4dX(mbni4`e_`&8vTE$AnKFG|vP+(pPQ-9oTHlaV41JnYln3b?F!Kgz80->l-6N&}lI8*Qd z&_pLJ*EQj^DjcvAH2|?8Sg~0?E_>5lG%-Xj|JaB-(6`upTooDu?kHgnG!rh7?l=)g z>R~vp@kHuVF@sg3bN&2hgtfBkLF~A2b=_qnsc<*MH^XcjshK}&!H7`??3sBejr2C5 zU9-dni37*hH`eK&`@)PVs}Pihu}J-}afgRA{YRoLTrTPS_!H3lt|Q6kGVS~V)AStXFNmIMpq>w#p&1&y{|y>;ji}-m+Sx=ndPW?O5YHv<%9+)a3* zfWdZVs-)jOh1ahE#G8YDNHy4^_J?+667O&i89;{iBJF!a(9hPKl5y}12~GT0WuV;e z87TKFfN~2;%-`r4H*nOo(5$MT_g#I!0MUz>QD6=2?3ju3VB=7!&G(>B@$PlvasGN@ ze1RF(Cfu$*tv9xY`W5xV*>oiAD4}~nCYvz_kRcYvFEOy-iHcd0K0L8>0o|R56*N2% zOK5Mqs~qrY6BB{(ik!PMa2$++dUt5+gxQN0CE_?%$w8i1#})u^oH_uBI#At2T0?4Lsw3?r+Tcw?(#j0x9WGby^Y@Pi@nkT$&(TpDT8nPt z+QFx3d%CH$1}(RVqoc8Zrf-15{f+}vH`u96?itO*x}5n$Be;Rf_NB=hs0)<4jUuTG z#I4p7>gU%YDmciL2Q8d~XU}XsVZ5FfHlPVrdqSVY7p!1sJ9giDu=sQ3Ts0iTlA#(X zEG&j&*vreZbB-C0=Z@{Y9A4z^bFXGM`=++ht3wy}nJBWPUz&)mK(9o2;~XDwN>+P; zT8FUulk)NiQq?ws`Rygcn078~;GAq>5Lu4lXkTdi+on*3JdLA+up&qEYI(lB3nt3t z$XH1z6LK=pN8UBU8fka)*Ho~djBH>B0g>!v1S12nKYOXALmbX!;gMD#T_v2cv~(lQ zG*8K?U!ht=0yNBXJO?M{t!SYiImuLp{b*4;a*dP)!5{|#^972D7)B3q{R+^X2=f0l5BqhbbczT=*Lk7LoxsSgNBqKVm!;D)%1Vx6lgWf9cWbFlh}YOpX(HhlnxY z{O(FE9z(0DLCspUK4KQZE108^1#xQJmR&nGZA=%Mk|DH4YpyvoN1vZ+N(NJpHXP$- zXOpVv|9aT}7-mBXB#EfSd(liLnrqMFXb0CWGIpB3#LtJ$?G*#QsD3r>+k%kB1)&mi-{RGbswVTSz6y zYn&o{OMICFrEBa>Q}rzvh&rr21u-FR`rH^}c+BRVnfup0TWS_gXJr?j4$mIIz`zD( zg`LK3{A*0l1BvU8q}ro_7n*Luo7>t=Y1>mXj7NnP(UzstjlF~7sZ&eyi0+)Q&d$O@ z5Ye9u6-v(^*@0KKz+@VmG=MOIKSsns*h#zsyeA=uXQXcSeRUIJ{D>n3>;|il1RRAu zgggkqPS{D?p;82gz40}S=UoE)D}pDp?( zwC{li1%+&)+puZ`vT7!wz>P^A_9#Q2jcsglV5h!$^sv-m>^%?)s2G7H18E;X`3`6x zyx0!8?RJe$ffEXq0)N@FHxbUsvK1=?uS)JaL-rQR4CQnYH!?q)xNmbRoBJ6fx02? zjns{B_gDMl-dx=r_txqzxOY{bRw$kFo$e~sPR}?_q2^pT7u-ELZ-pAIs77bF;HrrW zWmY&lU2!UMy6SYz>AKSmr<LVSM(+Q``tfO)9iV($Yg(|Rfk6?u^V)V4h3Rgv2yl<%}*M4Xx zcSXyN?St99AG>#NKNR<#y?fx^t4AR2{d)JrJ=h*5xLXx+IV82opF^{cq=r@RMj6y!O7k-3VX|#?e%DndUz{3 z;9F~it%X>OI&0af)7MU&cnXts8iK|QKzaQqfS*zMxClwj5<|q8Fi}KG3rzWGU`MOP^W4VqfS*MqfXVgj5<}{ zG3r!(&!|)NkWr`VF{4h^6Gok?9~gD2eq_|CdZXx~P_zIIwm65&4OewsH5AHLS@`RX ztGh4>rnsAEfwf>RE)=gaHI9A>eBW8cvBbUxj+S zKkDGW!5=kHD7;aovS+`8pP#)^&M)8lR4omUvX{O0MjgtYeb7E-&)%rlr{_Puct%~n zd|#HP56V&Y-beGr^A~CQxPE@`m;d>h)#XzlUTLXZ$dY*|!R z2r2>sK|lq~>aWu+I5L6VeP)~IoO{Z)n1ALnEEm0Xd;S;l@{ zh-1jlwEV6Ri@bQ6um4~uFP^S2`-3PB*aO{ zRpd=_U2}MgjnPy_xke1B~O+t$NqDm z847E;;D>!3!E+T}ps;Tz*#~v?^Xv;Q`L&M+K|aL8C9f#_T8NX-DcRubY;Gjq7TPJ< z;p=>;bap5ACJ!c$CQlesIwZs;A1i!7;V%_FqA=v89X&fGr$c$Et8g=glOc|q5SO-4 zxJ`(Y@H4PqS{TYpyM;KpyR;%~ONd1tmJQ{l-4q_Cu=1_+%FupkABFoVJSfECXW)az zI<}?5LY>l^6dtSac!h7nx$*P13a79t!#}6HaUKtPIEEZ3UFhM`sS4j0V(Osp$zQx> zBl&DkUOKOl{4q}+8;?E$Kjg0zV^ zW1{2ZB>Hl4EC{wWF2(4i<)soMU#E1h!iZa1e$>OU$I2$emX_5?&1IcKTzVqJWk%r) zg|AXLOW~FZw^KMz;p;;zK9rUCK14%#S@#e_r45%IK zEK>N15GRq#X`N*uma=8fVqeNWAIi&CDZEzU^&yruOtM0JK-o7r} z8k6XesFuBGQHaa-DZEEv$jkFR{j$SKemunGISThs*bZ^osSuY#9@s2zqU6~MYn!>D zJP{AEv?XmvwWYi))G1f`<;pi5hjK(Ch)H>0g$IT>A#&`?U-`8mPAmpq?#X)$!&ruP zLNdDiMulraEIQ?*L!C6P4Ry*VhPbMvd`i3yWoF>~e*-R`9O{(c>*07Wcv6#qC)j^j z;kgR$P#E&^1-|~1N-k{->_A?=!Ph~%)AHvO{#0}bhAe1K%`$TO55k{RyKwk<%ywQ&;I(oSLbcie3DC{cSOyL#^EB%Dz zv*fYlZzEiRJ}L5w!iGA5JiZn_Js)2s9#xE0c)Y?D3MUo5P2np;9Dg>%6@5ai>k?e~ zgkNvJP^V&$!b236ocI0Ib}DXaBwsHr@!Jw$X-j~`=K$Xay{W$boeGQm0Z$%l5o?n~ ztYbK56|+OE<$^2bc{;=k6@JVwyR>3O#Y)(C5ws4p+OJpeMh{oj0U!0{TNJJjF?HVe zb#^zB?^SjV`uMI;C$Tv3WMX;Zxx}v#`_3V*I1%E?0Sad*Y!rsPvc$KOrQ|IYZl`ds z5GNLdxH9j|So{p^M-M2SXowSwE4znn39-n-vZ1^(s_;04m2Z_-h4vf7VX%^4AL0h_ z9O1=Vc;!fi$A$Wp6BMpf_%4N~q5O2Oe0&0O6*(b)i36Y`9`S!Oql5h9rI~&ROgz`!s zN1H^mD^FqVkPG@Iyie&I_HgC#hGnC6s1wcc@s#M?=z{2CsX5wL;e3TBDqN)SXobrZ zjw@WPa1VvGTznYl`*5w&sZn@J^hV`ph(#Wj4dpdTe}=-^=UQc7w{fzP>-gNOipW9&+7_U*i;Fmf`GC$t>8 zfn3T)xA|pvg!1TzAx=oWf&Gs|9CJ}N@E`pyjnV(o7;Ox&=*OCQb?JFQ-`mvpCzCBa zU96nj#0o>K?~mzwZu%Zw?14Z(wmUW(J|myyFP8MSKqq2XD%>Z;avkmc#TLf;g*e#< zp7{F>?vJVK`am0+!}Rrd>@n21RE}1))YqhP-5vbJh6D(uZv)*a`^l+-y}x)~fM9Ky z#<82!-dKglL(?xWu?_x`TVUD0U#IW6iEqJuu-Mc<8?yUCtnXbkj&p3Dr>A46W2NJu zuh%p_TidnIu@!+ovE4d8`gZ|+?>)8}ZCK}(k$c6#J=GZE5ZpJ2y&u|)?NvE*FfBh3 zI~rnrZ%W4`o)PF^{LWIirNZq39M98v9`78;;p1+QU$?$S*4IjxqtAMOi4Wu5748N9 z{c8lhhSzKU`1Z8^l=x0$=Jnowd~jMmH}1<|ZEk#olJ5&}{BV3^hy#R{AJ_A~l8@8# zwZ2Bv<5+weYTWFV(qmnsD6o`Bw;Xj%52S+Q530R6kUZk;#}@%V;p1gWXD#Hxn)z9U zMZVtClk0^z=4oC#U`z}Cov*$3t069 zJ-?qXtk;JP`dU`lr_P&PjS0Fh|EIzO5I29lQZ*8IoIf7c0Z;Sr<)?wMCXm0X5^qbq zR_SA%Q1yg&jPoqk1oBt47POwwRBZ*`>EQ-8u?7q5H<0VHSevEoXt}Oe*{}K*?W(#8 zHiNcntjk^qJor4-t9{n^eEF>X#5y*e8yb&P^1lX8fE_>Isx(%4DEBARI_C@Db&fW* zZyH|+f3PMG`bCw(x{q84T!kxx^HFaVt|8K6g2uYf{xx_R><8nF#;YI?#vG0HxN#xy z^XIAFO|avSlT{jTRq_jgx1XbZ?VHBu%Qxi@o^AY6o^!V9+`iyL$$#Mf6nMBJ&sTSH z@mw_U9AH;49{R;|l#t*#O7O+PQ}PV?awngX)8tQ*&mjL_;6ohY!=3*L{#(}gathCf zTeZ$g@bLyuSz6<1XM75^nCC5dn)-ce_7+d9M@8Sxq(8mIA8Rl19{Pii!a~xU{`98J zzO>fc5r29+;!ke}{+RdOw!pKU!<2|Wx$=BCo{SeC?s2A)OO5ximV4x=b#n?&utOr= z-XnIbo2h>{b?%nBOtpAxmWTy#SOABg;E0l`b~_Nmf>}cS!xAwZg@@qq5L`T5B2hAL zvF&kMI7)nj_+WYxx1c{Q*v1x&*dqEl$=V0sT8M9Ex%FRxU#CttmYT&#&SKeFY|AVj zoh25mSv)&48G)HR``0nTGsOox&a+>^XwPJ{XSyR$YNmUiaN60<9GEFBvR|e@GkHd4 zGX66ew<5-WJL6wOuV%8|A{S4L!`eOM@>GAVSilqi(w0xCIe`8Tp#K9{;{aO5%#2b4 z7>6~YY1X(`Ly}MZe0MVN6y{q#&sSGk$Y%t)(oUS3GpUp0(In+bMy8fAsijUWEz~*> z!e*wNQ>)g+I}6rJ&QX!j&svs!iP5NKG~Qt}Y8j1M`drIs)G~&(>>ag?L#D*Rew6wf z_$2ZW;vsC~LfSt`A5Mx7_8?}_G0JNwuVK4t=)+U=p@zMnhBekOyN*zQC|fj?`a`MT zg_0~=dTR!;?ff%vy)zCx%iae58{%I%4~fJnCBF>3jaV?pspC3%BDdclmtJ$Ry&rtI zGlr7if-k40vE^t>;;R{da%O|yBNog*NbNX^_<;YO9A(XXX{mXNI`hf@#gXjo zLHjLOZx6{&Qz3HmBJqo?@kQqUGU`mEowuZvdB&Ou3-z>6&q&rY0#~suQTiWc&PUmz zD33-N?I=qXv+bWVs>Lj|jd%+qT+9d;J9sO{>dB+Uv``>zv`blIG2>s%Xcx1_o{V!V za!;qXSC*0Q={yB%#m>{@_k$m%ox^O=A<;A+c(G*!K466BGY%gxsvj@{^BI#B&Na~g zg8E-LKY_JDk~5Hqov&!&3u!wr^3CeWqkAb|CK+q?vfjP4zs&v_bjH&DUfTby`5clw zMk$Z2$g}GqiOLzbnvmx)K2h%|YiUB zc7|7q=jc6-6LC^DXFx# z!$Nz?dkD8HSXVb5?MB>`xT$D@q@@pt1Rwel{uL#cSf2u)7Hm#XK3{4v^C@{nas%Ux zWcWkW&!Np%iI-CUI3?fXSXxB<8y3!;Urc#1 zEq_KXuf|}VMw`Xv7;saTYD&$f>}6}H(~**AX#WLD9-yRxcD!@3f_MdwUd1ze74c<) z@h+v*^#jU(Ks=OqDDe%%HxR#0{5tV0;#q?2EtJG5NfIY4tYNG{lnkTfBss43P~#Hj zSPl7b>I@gT{Se#Ro07hiG^6BhO75nGyY24KK@3FWk&!JhRDLaD;5Zqb+2dF~_OzQtncoO-YoJ zC?!#9&J#JTNvXMV)Vxg1F4XKoO?lr?dXubP+7oAUG|Q$Wn>x9aPorcSCDW)QcS$nv zE~kYSOHEn$XPJCc2mbZ9IweyJLDEAdOXR#=3F-st2f~Wb@N8MFuxH`>p~4rFbHqW+ zL~@LL2Y3^_)!EME;Z4#K6E)rW{^TQ^Ct9Vd!~$odKXYcfJte+<%2D_jXP_P6f%g`U zdEWQ24c^2&_xDdL;m>NgSBLF7&_DAm}LQgfs_~0m3X~wFIGW z<848MHwX>h7&KN1C<^KhS_SF_x(X!62ZOE$jR1`VjRQ>p!3XOu5YCS^9rPe*4hYe; zaF@e+0<;VSpRDIWt8ng{%Xu~H$w!jEOa3PLW^!-TTSUB{{0R9%^4o=5<;1(m=Tg#2 zvfTV5Is3dhNc=nUdh(^@kCT5yeu(@R!mSR(pO9~*#L)g6^5-~?*N|_bWDEH&@*U)d z$p1U}8|2%`KlM0lR^q+Iab^N?_bxNdOt)?{51Kj1$3=Jxd>P(meBP`wYt4GI$!s;- z%}%q&sx|w}VRPJ^vTUn~m2KtV-P?St$SSkqRu8M%>T3n0O zo|AdY)RDi-b=aH4n`O*3Ps^&se9BeSe)irYl+TsbhPmDTQsT@~ySZ+g%Xy`hjBe&1 zd1cbw$v0N&psX9rL0(J$POfuIyhkQAGyOn&CeJA8w>}SAt5$~{<03WhffdAZ)_2L^`sc)CD!+a{U7uq#7-S2Gd zXW=bs%jBX52k&sBH{wlh8(l-j6&YXtn{@!ZzqK3O1>H}C<5tw}*XTX(*HC7)|DN}1 z^`7@?zURG~?|F~od){;Tp7(0L=e?Tmd82pYJ#TOP@!sZ+z}HN?*P3OT<4Xb;!<$Pm z>a>E-t?~H>j2bmzvY9bv)TAGq*<;4sa+{eqcH*tK zn8oAjMvXB~PWb6hMww?POuXY3^ZbNc$4oG*$k&puC*OpFb!O|WqO<+hy0H_@&f5fk zJV`7cnDo<$33O(g0Myea|Re(C{DeGUIEzVocR_ASJoAZuS@4V}5ciwZ} zcRp}-;tSayI=h_R&YzsGoD=vjJWn})aZWoad@-@-Zq@!`l(27VClOX0Cb6AB!`c&G>5f4(B81kn@%6m<)LSp7SxHcEWYB z_ZGbVgkSVV4@QeV!1uk6IQSqC_TENoK1F2z;$~uR8(OvxQ9kW9!QMM)<$gpwXzxYX+m1*a#96W3EMq(CFq#VAgYQtpaNiF%eX;ikB={el zKbmInbceafEp2aD3*=ZjW(1078l`+RD+~PcrnO_G8yx1IZu$;?nZYL z3~ZJ!Y+&C>e=Flvd@JL1d@-Xb%Ukv=PBbli}&XxLnM0aV^{}GL7$$2mTWSc!7pJXEMy<<5lPO0zwN#a zj?6O_au4&3rVA?w+HUWr8=0Gy~6yWPxW#vcX$M5Nq^0%xvg+ZNS?_+JWaqFoUBP=7XbWf_wc@ zMz0Kxf3gylf3mU;knEwJpxtJmDLhYSrSQC*5!d(3!dl$MoGtUY>%N9eeVOPppih8o zj0iH12R%FknTj@AebX}eT93;S6XPVLu8dGJF0s$ZS+~%7Z-mXjJb^FpV9(jK%1Cb;;+Hj;^C_|VHIVo@h}PN|<$s7{wi$)~ znXVCQz5=y30{=fkcx({AKa`F`YA23aF23m##0KmBD*7hzEM!kbM&TiVDIlebi=N1B6{K8V8k&!w^&;ICaZ{+bl%6B+_ zY9P;%bDp-Jft8N5m*Z?&Zezaablzr4{F-;3`iMkAD3@F5?^(EJkx?CO_4D}hbSorB zF{&5`t;0OmK>iT+B2FUpUCv7Qx=v&kbPlEt+rZ)0 z^mqS^{hQB;iqA=ZC~WV5Z)c;EIDXPJ1<96VRpYvXl-g)jr*?u4fF1(9gbw-wxg+T&Pn zCCfHloWGVv!p52C9PPTD5bxX39+1-xcnNSrN;cTP&RlByWDSC@+CL+H+gxFPV2bQ< zCiASc1v>u$a_%r!oQeKjTfa=DoKva4G$#9DWgn% z)+sXuLiY8h+WM0zI2+-3f!))z$9+=KO%s06F0c#Fi6ZByUx2$+m)JL;j-y1ln~2q!=<7Dl9#iN# z_rp#QQ=D__CA7<mr@(Bjf1)Yr|@0F{4^z?tM|`WPJSzLOgFm`Sw%R#5h!=ViX0fpJgf(JMo8I+<)e*SW1zDSMb{<21v#>J#n-_7`&6;F1n=hRk6y57>Kw zWek>iS;omZr~~A5H|;nkJz@%RH?F@M1N{y3PJzUSLnhn#C&MFGF)rOS^;DK*J9z%Q9(Pom9mJZv`6M48JjsC z_D8*4kaO?&^uKXVZZXaDc;w(08mz+F+c)7@Km&keZ(-k1` z8|PKZB35mK`GRwSKc4$`+oa)`zxg>T^?GX~ zA&q2AH<&8KI9=7AmnJqp>4zCXj@(`fr7 zj-SNwlQ@17$I}Eo`0TAWVe1=o(T-uD(N^{8S)h5K2SAU8l)6#c4wAL%e#GD>pxd47 z(+fbq1U(8`3faw!&+YK*@l;p)@zcXWH+qyRkhn2k66eg+O`uvQ^YqU_3qi9%{~S_k zw8U7>!M~@Ayf47hXII!o$h&Kh+XqZX>t)EW&ai)Cimbud_5gLpb;J~Gp@%#w5MBE* zlZF0PVtokww||i=SC#tQ!B<}GlxM>loLUK5Ajf^mp$!}tAEaK^c?HH^ z@#z8++bL~53(*&e!37}1v_#^Hc>D&m2J|ZEEzm}f;Fot?_J0rB391Dx0j*@*B>rb3 zoD(@OD%L^Fzr{AS2jx1jtel$*MHOa)Ei}XASs$B=>^>$3b+)zt9_RnN*p9-u(iZtO z0qq=RE)zX`|Jc**3Eh6sxd*yEvHcNrbD%p8x<7(0^e_)+gDOE4pmI=#zb(eL5Y!3O z8I)~pF_+@Ys1fUN(+QP%t!QhW=>lpU zY;phTJ63_|N~%wtvg$Ekh|X5D{SadO4*Kyd{BQumuV_V}r*ZGB7?f)*M9dbNPS$;9 zAgHr7+r+G+xc9IZW5zJ7JAtjP=-(Ylg|fwdA!R6QFEs5D_gqkWtCMNzIHo(+WeI$> zJ7N9Iw6Jb77o*K`@5I2K;WGzgu`jWWAI+Cb7YOGtkwbUX7@Kqx~V+7wYR% z4{Xao?Lbnt1>zfXHk!+kcco5SwEuOJi#YVR{|Pxf9)8S%AEWVnb++jmc^T(P>i>JJ ze;$L}+qTC#=L+Cu zYq=hv6X@#>{K+xBp;L^z@BN+cL*5?0Xzar;th-E0YXkbqGLvuKOnrhUBeKnNra7ns z_7CBIi|s(dC#7%-gybHj{3_T9lmyA|7#I5_zwXJt9IFP&Z+=pyoxjEN6Y|AplkiD? zOT=%BmfT4cy$+zhAmkyp^4p+`{Ky483YrF*4k`pygL;7ypu0fGP16r_3rL<95_=V( z&d6zb0x1(DzTwwmZpBx5&PMwxI#N!4%Y<$U&xME|^iOQz*$=h}tA5%L{{tI6{bOi1 z^`)F+U1vrJ;i=1VYXtmjNW-x|+%D%+Hy7Jgz(QS2KT, #[serde(default)] animations: AnimationsConfig, @@ -119,7 +117,6 @@ impl Config { ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?; } self.notifications.validate().map_err(ConfigError::Binding)?; - self.desktop_widgets.validate().map_err(ConfigError::Binding)?; self.xwayland.validate().map_err(ConfigError::Binding)?; for daemon in &self.autostart { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { @@ -637,36 +634,16 @@ mod tests { packaged.xwayland.enabled, "packaging/config.kdl must ship X11 support enabled, matching the default" ); - let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; + let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; let config = Config::parse_source(source).unwrap(); config.runtime_config().unwrap(); assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96); - assert_eq!(config.desktop_widgets.clock.outputs, ["DP-1"]); assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0])); } #[test] - fn desktop_clock_settings_are_validated_before_live_publication() { - assert!( - parse( - "desktop-widgets {\n clock {\n enabled #true\n anchor \"center\"\n time-format \"%H:%M\"\n }\n}\n" - ) - .runtime_config() - .is_ok() - ); - for source in [ - "desktop-widgets {\n clock {\n width 8192\n }\n}\n", - "desktop-widgets {\n clock {\n opacity #nan\n }\n}\n", - "desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n", - "desktop-widgets {\n clock {\n time-zone \"invalid/zone\"\n }\n}\n", - ] { - assert!( - Config::parse_source(source) - .and_then(|config| config.runtime_config()) - .is_err() - ); - } - assert!(Config::parse_source("desktop-widgets { clock { anchor \"wrong\"; }; }").is_err()); + fn removed_desktop_widgets_are_rejected_before_runtime_publication() { + assert!(Config::parse_source("desktop-widgets { clock { enabled #true; }; }").is_err()); } fn parse(source: &str) -> Config { @@ -688,11 +665,6 @@ mod tests { fn a_configuration_without_an_xwayland_section_keeps_the_defaults() { let source = r#" layout-mode "scrolling"; - desktop-widgets { - clock { - time-format "%H:%M"; - }; - } scroll-factor 1.0; "#; let config = parse(source); diff --git a/crates/ferese-config/src/desktop.rs b/crates/ferese-config/src/desktop.rs deleted file mode 100644 index 27315be..0000000 --- a/crates/ferese-config/src/desktop.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! Shared desktop-widget configuration: validated before live publication. -use serde::Deserialize; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] -#[serde(default, deny_unknown_fields)] -pub struct DesktopWidgets { - pub clock: Clock, - pub notes: Vec, -} - -impl DesktopWidgets { - pub fn validate(&self) -> Result<(), String> { - self.clock.validate()?; - if self.notes.len() > 32 { - return Err("At most 32 sticky notes are supported.".into()); - } - let mut ids = std::collections::HashSet::new(); - for note in &self.notes { - note.validate()?; - if !ids.insert(¬e.id) { - return Err("Sticky note IDs must be unique.".into()); - } - } - Ok(()) - } -} - -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(default, deny_unknown_fields)] -pub struct StickyNote { - pub id: String, - pub enabled: bool, - pub interactive: bool, - pub title: String, - pub text: String, - pub outputs: Vec, - pub anchor: Anchor, - pub margin_x: i32, - pub margin_y: i32, - pub width: u32, - pub height: u32, - pub font_family: Option, - pub text_size: f32, - pub title_size: f32, - pub color: Option, - pub background: Option, - pub opacity: f32, - pub padding: f32, - pub gap: f32, - pub alignment: Alignment, -} - -impl Default for StickyNote { - fn default() -> Self { - Self { - id: "note".into(), - enabled: true, - interactive: true, - title: "Note".into(), - text: String::new(), - outputs: Vec::new(), - anchor: Anchor::TopRight, - margin_x: 48, - margin_y: 80, - width: 320, - height: 240, - font_family: None, - text_size: 16., - title_size: 18., - color: None, - background: None, - opacity: 0.9, - padding: 20., - gap: 8., - alignment: Alignment::Left, - } - } -} - -impl StickyNote { - pub fn on_output(&self, name: Option<&str>) -> bool { - self.enabled && (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|s| s == name))) - } - /// Text/style changes repaint in place, never remap the desktop card. - pub fn same_surface(&self, other: &Self) -> bool { - self.id == other.id - && self.enabled == other.enabled - && self.interactive == other.interactive - && self.outputs == other.outputs - && self.anchor == other.anchor - && self.margin_x == other.margin_x - && self.margin_y == other.margin_y - && self.width == other.width - && self.height == other.height - } - - pub fn validate(&self) -> Result<(), String> { - let invalid = |field: &str| Err(format!("desktop_widgets.notes[{}].{field} is invalid", self.id)); - if self.id.is_empty() - || self.id.len() > 64 - || !self - .id - .bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') - { - return invalid("id"); - } - if self.text.len() > 16384 || self.title.len() > 256 { - return invalid("text/title"); - } - if !(120..=1200).contains(&self.width) || !(80..=1200).contains(&self.height) { - return invalid("width/height"); - } - if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) { - return invalid("margin"); - } - for (name, value, min, max) in [ - ("text_size", self.text_size, 8., 96.), - ("title_size", self.title_size, 8., 96.), - ("opacity", self.opacity, 0., 1.), - ("padding", self.padding, 0., 64.), - ("gap", self.gap, 0., 64.), - ] { - if !value.is_finite() || !(min..=max).contains(&value) { - return invalid(name); - } - } - for color in [&self.color, &self.background].into_iter().flatten() { - if !color.is_empty() - && color != "theme" - && !color - .strip_prefix('#') - .is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())) - { - return invalid("color/background"); - } - } - if self.font_family.as_ref().is_some_and(|s| s.len() > 128) - || self.outputs.len() > 32 - || self.outputs.iter().any(|s| s.is_empty() || s.len() > 128) - { - return invalid("font/outputs"); - } - Ok(()) - } -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum Anchor { - #[default] - TopLeft, - TopCenter, - TopRight, - CenterLeft, - Center, - CenterRight, - BottomLeft, - BottomCenter, - BottomRight, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum Alignment { - Left, - #[default] - Center, - Right, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ClockStyle { - #[default] - Pixel, - Minimal, -} - -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(default, deny_unknown_fields)] -pub struct Clock { - pub style: ClockStyle, - pub enabled: bool, - /// Empty means every output; otherwise exact connector names. - pub outputs: Vec, - pub anchor: Anchor, - pub margin_x: i32, - pub margin_y: i32, - pub width: u32, - pub height: u32, - pub font_family: Option, - pub bold: bool, - pub time_size: f32, - pub date_size: f32, - pub time_format: String, - pub date_format: String, - pub time_zone: Option, - pub show_date: bool, - pub lowercase: bool, - pub color: Option, - pub date_color: Option, - pub opacity: f32, - pub alignment: Alignment, - pub gap: f32, - pub padding: f32, - pub background: Option, -} - -impl Default for Clock { - fn default() -> Self { - Self { - style: ClockStyle::Pixel, - enabled: false, - outputs: Vec::new(), - anchor: Anchor::TopLeft, - margin_x: 64, - margin_y: 80, - width: 440, - height: 320, - font_family: None, - bold: false, - time_size: 128., - date_size: 18., - time_format: "%-I:%M %p".into(), - date_format: "%a, %b %-d".into(), - time_zone: None, - show_date: true, - lowercase: true, - color: None, - date_color: None, - opacity: 0.9, - alignment: Alignment::Center, - gap: 4., - padding: 12., - background: None, - } - } -} - -impl Clock { - pub fn on_output(&self, name: Option<&str>) -> bool { - self.enabled - && (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|output| output == name))) - } - - pub fn labels(&self, now: &jiff::Zoned) -> Result<(String, String), String> { - let zoned = match &self.time_zone { - Some(zone) if !zone.is_empty() => { - now.with_time_zone(jiff::tz::TimeZone::get(zone).map_err(|e| e.to_string())?) - } - _ => now.clone(), - }; - let time = jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?; - let date = jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?; - Ok(if self.lowercase { - (time.to_lowercase(), date.to_lowercase()) - } else { - (time, date) - }) - } - - pub fn validate(&self) -> Result<(), String> { - let invalid = |name: &str| Err(format!("desktop_widgets.clock.{name} is invalid")); - if !(64..=1600).contains(&self.width) || !(32..=800).contains(&self.height) { - return invalid("width/height"); - } - if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) { - return invalid("margin_x/margin_y"); - } - for (name, value, min, max) in [ - ("time_size", self.time_size, 8., 240.), - ("date_size", self.date_size, 8., 96.), - ("opacity", self.opacity, 0., 1.), - ("gap", self.gap, 0., 64.), - ("padding", self.padding, 0., 64.), - ] { - if !value.is_finite() || !(min..=max).contains(&value) { - return invalid(name); - } - } - for color in [&self.color, &self.date_color, &self.background].into_iter().flatten() { - if color.is_empty() { - continue; - } - if !color - .strip_prefix('#') - .is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())) - { - return invalid("color"); - } - } - if self.font_family.as_ref().is_some_and(|s| s.len() > 128) - || self.time_format.len() > 128 - || self.date_format.len() > 128 - || self.outputs.len() > 32 - || self.outputs.iter().any(|s| s.is_empty() || s.len() > 128) - { - return invalid("font/format/outputs"); - } - self.labels(&jiff::Zoned::now()) - .map(|_| ()) - .map_err(|e| format!("desktop_widgets.clock: {e}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn widgets_reject_removed_radius_options() { - assert!(crate::from_str::("clock { radius 12; }").is_err()); - assert!(crate::from_str::("note id=\"note\" { radius 12; }").is_err()); - } - #[test] - fn notes_reject_duplicate_ids_and_unbounded_content() { - let note = StickyNote::default(); - note.validate().unwrap(); - assert!( - DesktopWidgets { - clock: Clock::default(), - notes: vec![note.clone(), note.clone()] - } - .validate() - .is_err() - ); - assert!( - StickyNote { - text: "x".repeat(16385), - ..note.clone() - } - .validate() - .is_err() - ); - assert!( - StickyNote { - opacity: f32::NAN, - ..note.clone() - } - .validate() - .is_err() - ); - assert!(note.same_surface(&StickyNote { - text: "edited".into(), - ..note.clone() - })); - } - - #[test] - fn defaults_are_safe_and_opt_in() { - let clock = Clock::default(); - clock.validate().unwrap(); - assert!(!clock.on_output(Some("HDMI-A-1"))); - } - - #[test] - fn output_filters_do_not_guess_monitor_names() { - let clock = Clock { - enabled: true, - outputs: vec!["DP-2".into()], - ..Clock::default() - }; - assert!(clock.on_output(Some("DP-2"))); - assert!(!clock.on_output(Some("eDP-1"))); - assert!(!clock.on_output(None)); - } - - #[test] - fn rejects_bad_sizes_colors_formats_and_zones() { - for clock in [ - Clock { - opacity: f32::NAN, - ..Clock::default() - }, - Clock { - width: 8192, - ..Clock::default() - }, - Clock { - color: Some("red".into()), - ..Clock::default() - }, - Clock { - time_format: "%".into(), - ..Clock::default() - }, - Clock { - time_zone: Some("not/a-zone".into()), - ..Clock::default() - }, - ] { - assert!(clock.validate().is_err()); - } - } - - #[test] - fn formats_timezone_and_lowercase() { - let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap(); - let clock = Clock { - time_zone: Some("Africa/Lagos".into()), - date_format: "%-d %b".into(), - ..Clock::default() - }; - assert_eq!(clock.labels(&now).unwrap(), ("10:05 pm".into(), "26 sep".into())); - } -} diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index 6ba5a02..2babc96 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -1,4 +1,3 @@ -pub mod desktop; pub mod families; pub mod notifications; pub mod panel; @@ -86,7 +85,6 @@ fn field(name: &str, parent: &str) -> String { "window-rule" => "window_rules".into(), "output-profile" => "output_profiles".into(), "output" => "outputs".into(), - "note" => "notes".into(), "panel" => "panels".into(), "group" => "groups".into(), "item" => "items".into(), @@ -101,7 +99,6 @@ fn is_records(key: &str, parent: &str) -> bool { "", "bindings" | "window_rules" | "output_profiles" | "autostart" | "panels" ) | ("output_profiles", "outputs") - | ("desktop_widgets", "notes") | ("start" | "center" | "end", "groups") | ("groups", "items") ) @@ -149,6 +146,9 @@ fn object(doc: &KdlDocument, parent: &str) -> Result, Error> for node in doc.nodes() { let key = field(node.name().value(), parent); + if parent.is_empty() && key == "desktop_widgets" { + return Err(Error("Unknown configuration section: desktop-widgets".into())); + } let value = node_value(node, &key, parent)?; if is_records(&key, parent) { let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new())); @@ -187,7 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result { "bindings" => &["keys", "action", "argument"], "output_profiles" => &["name"], "outputs" => &["match"], - "notes" | "panels" | "groups" | "items" => &["id"], + "panels" | "groups" | "items" => &["id"], "autostart" => &[], "window_rules" => &[], _ => unreachable!(), @@ -678,7 +678,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) -> "bindings" => &["keys", "action", "argument"], "outputs" => &["match"], "output_profiles" => &["name"], - "notes" | "panels" | "groups" | "items" => &["id"], + "panels" | "groups" | "items" => &["id"], _ => &[], }; let position = positions.iter().position(|p| *p == rest[0]); @@ -770,7 +770,6 @@ fn node_name(key: &str, parent: &str) -> String { "window_rules" => "window-rule".into(), "output_profiles" => "output-profile".into(), "outputs" => "output".into(), - "notes" => "note".into(), "panels" => "panel".into(), "groups" => "group".into(), "items" => "item".into(), @@ -787,7 +786,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result "bindings" => &["keys", "action", "argument"], "output_profiles" => &["name"], "outputs" => &["match"], - "notes" | "panels" | "groups" | "items" => &["id"], + "panels" | "groups" | "items" => &["id"], _ => &[], }; let mut child = KdlDocument::new(); @@ -880,44 +879,6 @@ mod tests { document.set("theme.file", serde_json::json!("")).unwrap(); } - #[test] - fn widget_output_arrays_and_command_names_survive_edits() { - let source = r#"commands { - my_command "program" -} -desktop-widgets { - clock enabled=#true { - outputs "DP-1" - } - note "n" text="x" { - outputs "DP-2" - } -} -"#; - let mut doc = Document::parse(source).unwrap(); - assert_eq!(doc.get("commands.my_command").unwrap(), &serde_json::json!(["program"])); - assert_eq!( - doc.get("desktop_widgets.clock.outputs").unwrap(), - &serde_json::json!(["DP-1"]) - ); - assert_eq!( - doc.get("desktop_widgets.notes.0.outputs").unwrap(), - &serde_json::json!(["DP-2"]) - ); - doc.set("commands.new_command", serde_json::json!(["two", "words"])) - .unwrap(); - doc.set("desktop_widgets.clock.outputs", serde_json::json!([])).unwrap(); - let reparsed = Document::parse(&doc.to_string()).unwrap(); - assert_eq!( - reparsed.get("commands.new_command").unwrap(), - &serde_json::json!(["two", "words"]) - ); - assert_eq!( - reparsed.get("desktop_widgets.clock.outputs").unwrap(), - &serde_json::json!([]) - ); - } - #[test] fn inline_comments_and_failed_edits_leave_document_intact() { let mut doc = Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap(); @@ -970,6 +931,47 @@ autostart "program" "two words" enabled=#false assert_eq!(reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), 2.0); } + #[test] + fn output_arrays_and_command_names_survive_edits() { + let mut doc = Document::parse( + "output-profile desk { output match=\"DP-1\" { position 0 0; }; }\ncommands { lock-screen \"locker\"; }\n", + ) + .unwrap(); + doc.set("output_profiles.0.outputs.0.position", serde_json::json!([100, 200])) + .unwrap(); + doc.set("commands.lock-screen", serde_json::json!(["ferese-lock", "--lock"])) + .unwrap(); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!( + reparsed.get("output_profiles.0.outputs.0.position").unwrap(), + &serde_json::json!([100, 200]) + ); + assert_eq!( + reparsed.get("commands.lock-screen").unwrap(), + &serde_json::json!(["ferese-lock", "--lock"]) + ); + } + + #[test] + fn edits_preserve_comments_and_multiline_authored_metadata() { + let mut doc = Document::parse("// keep this\ncustom { text \"old\"; }\n").unwrap(); + doc.set("custom.text", "one\ntwo".into()).unwrap(); + assert!(doc.to_string().contains("// keep this")); + let reparsed = Document::parse(&doc.to_string()).unwrap(); + assert_eq!(reparsed.get("custom.text").unwrap(), "one\ntwo"); + } + + #[test] + fn removed_desktop_widgets_are_rejected_without_affecting_authored_metadata() { + for section in ["desktop-widgets", "desktop_widgets"] { + let source = format!("{section} {{ clock {{ enabled #true; }}; }}"); + let error = Document::parse(&source).unwrap_err().to_string(); + assert!(error.contains("Unknown configuration section"), "{error}"); + assert!(crate::from_str::(&source).is_err()); + } + assert!(Document::parse("panel main { metadata { desktop-widgets \"custom\"; }; }").is_ok()); + } + #[test] fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() { for source in [ @@ -982,31 +984,4 @@ autostart "program" "two words" enabled=#false assert!(Document::parse(source).is_err(), "{source}"); } } - - #[test] - fn edits_preserve_comments_commands_and_note_contents() { - let source = r#"// personal comment -commands { - screenshot-full "ferese-screenshot" "--full" -} -binding "Swipe3Left" "move" "left" -desktop-widgets { - note "a" text="line one" -} -"#; - let mut doc = Document::parse(source).unwrap(); - doc.set("desktop_widgets.notes.0.text", "one\ntwo".into()).unwrap(); - assert!(doc.to_string().contains("// personal comment")); - let reparsed = Document::parse(&doc.to_string()).unwrap(); - assert_eq!( - reparsed.get("commands.screenshot-full").unwrap(), - &serde_json::json!(["ferese-screenshot", "--full"]) - ); - assert_eq!(reparsed.get("desktop_widgets.notes.0.text").unwrap(), "one\ntwo"); - doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())]) - .unwrap(); - assert_eq!(doc.get("desktop_widgets.notes").unwrap().as_array().unwrap().len(), 2); - doc.remove("desktop_widgets.notes", 0).unwrap(); - assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b"); - } } diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index 7196db6..01feffc 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -20,33 +20,6 @@ pub fn format_bar_time(now: &Zoned) -> String { if hour < 12 { "am" } else { "pm" } ) } - -pub fn stacked_digits(label: &str) -> Option<[String; 2]> { - let mut parts = label.split_whitespace(); - let time = parts.next()?; - - if let Some(period) = parts.next() - && !period.eq_ignore_ascii_case("am") - && !period.eq_ignore_ascii_case("pm") - { - return None; - } - - if parts.next().is_some() { - return None; - } - - let (hour, minute) = time.split_once(':')?; - if !(1..=2).contains(&hour.len()) - || minute.len() != 2 - || !hour.bytes().chain(minute.bytes()).all(|digit| digit.is_ascii_digit()) - { - return None; - } - - Some([format!("{hour:0>2}"), minute.to_owned()]) -} - pub fn month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) { let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset; let year = month_index.div_euclid(12) as i16; @@ -140,15 +113,6 @@ pub fn grid<'a, M: Clone + 'a>( #[cfg(test)] mod tests { - #[test] - fn pixel_clock_stacks_only_hour_and_minute_formats() { - assert_eq!(super::stacked_digits("2:45 pm"), Some(["02".into(), "45".into()])); - assert_eq!(super::stacked_digits("14:45"), Some(["14".into(), "45".into()])); - assert!(super::stacked_digits("14:45:30").is_none()); - assert!(super::stacked_digits("Today 14:45").is_none()); - assert!(super::stacked_digits("14:45 pm extra").is_none()); - } - #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ diff --git a/docs/configuration.md b/docs/configuration.md index aafa526..aa1cae8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,7 +2,7 @@ Edit `~/.config/ferese/config.kdl` (or `$XDG_CONFIG_HOME/ferese/config.kdl`), or open **Control Center → Settings**. Start with the [example](../packaging/config.kdl), -which includes app and media shortcuts, appearance, widgets, and optional startup +which includes app and media shortcuts, appearance and optional startup and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness keys need `brightnessctl` and audio keys need `wpctl`. This reference covers layouts, appearance, input, and desktop behavior. @@ -63,7 +63,7 @@ can also enter a path directly. Settings keeps `config.kdl.settings-backup` before saving. File edits reload automatically, including atomic editor saves, so changes to -appearance, wallpaper, motion, input, bindings, rules, layouts, displays, widgets, +appearance, wallpaper, motion, input, bindings, rules, layouts, displays, and login items take effect live. If a reload is invalid, Ferese reports the error and keeps the last working configuration. @@ -339,7 +339,7 @@ Filled controls choose a contrasting text color automatically. | `border-width` | number ≥ 0 | `1` | Window border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | -| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; fractional radii are preserved; 0 makes them square | +| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards and interaction backgrounds; fractional radii are preserved; 0 makes them square | | `control-gap` | number ≥ 0 | `12` | Right-side control spacing | | Section / key | Type | Default | Meaning | diff --git a/packaging/config.kdl b/packaging/config.kdl index 9b9a8b0..0c00844 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -207,33 +207,6 @@ window-rule app-id="org.pulseaudio.pavucontrol" floating=#true window-rule title="Picture-in-Picture" floating=#true window-rule title="Choose a wallpaper" floating=#true -desktop-widgets { - clock enabled=#false { - style "pixel" - anchor "top_left" - margin-x 64 - margin-y 80 - width 440 - height 320 - time-size 128 - date-size 18 - time-format "%H:%M" - date-format "%a, %b %-d" - show-date #true - opacity 0.9 - } - note "today" enabled=#false { - title "Today" - text "Review the project\nTake a break" - anchor "top_right" - margin-x 48 - margin-y 80 - width 320 - height 240 - text-size 16 - } -} - notifications { show-popups #true do-not-disturb #false diff --git a/scripts/installer/inventory.json b/scripts/installer/inventory.json index 6bcbffb..5e838c4 100644 --- a/scripts/installer/inventory.json +++ b/scripts/installer/inventory.json @@ -90,11 +90,6 @@ "target": "licenses/Comfortaa-LICENSE.txt", "mode": "0644" }, - { - "source": "assets/fonts/Cantarell-LICENSE.txt", - "target": "licenses/Cantarell-LICENSE.txt", - "mode": "0644" - }, { "source": "assets/themes/licenses/*.txt", "target": "licenses/theme-{name}", diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index 2e06cfb..508d87f 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -1,8 +1,8 @@ use cosmic::widget::column; use super::{ - Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input, - visuals, widget, + Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals, + widget, }; impl App { @@ -118,56 +118,6 @@ impl App { let labels = labels.width(Length::Fill); let path = field.path.clone(); let control: Element<'static, Message> = match field.kind.clone() { - Kind::Font => { - let value = self - .inputs - .get(&path) - .cloned() - .unwrap_or_else(|| self.draft.string(&path, "")); - let families = fonts::families(); - let selected = if value.is_empty() { - Some(0) - } else { - families.iter().position(|family| family == &value) - }; - let selection_path = path.clone(); - let commit = field.clone(); - column([]) - .spacing(4) - .push( - ferese_theme::controls::select( - cosmic::iced::widget::pick_list( - families, - selected.map(|index| families[index].clone()), - move |family: String| { - let value = if family == "Default font" { - String::new() - } else { - family - }; - Message::SelectFont(selection_path.clone(), value) - }, - ) - .font(self.font) - .text_size(12) - .width(225), - palette, - ) - .width(225), - ) - .push( - text_input("Default font", value) - .font(self.font) - .padding([4, 8]) - .style(visuals::input_style(palette)) - .on_input(move |value| Message::Draft(path.clone(), value)) - .on_submit(move |_| Message::Commit(field.clone())) - .on_unfocus(Message::Commit(commit)) - .width(225) - .size(12), - ) - .into() - } Kind::Toggle(default) => { let enabled = self.draft.boolean(&path, default); ferese_theme::controls::switch(enabled, palette) diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index c7bb53d..a2e6ec4 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -119,7 +119,6 @@ enum Message { PanelPreviewResolved(ferese_config::panel::layout::Resolution), PanelPreviewOverflow, PanelEdit(panel_edit::Action), - SelectFont(String, String), Draft(String, String), Commit(Field), Range(Field, f64), @@ -146,9 +145,6 @@ enum Message { DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result), PreviewLock, LockPreviewStarted(Result<(), String>), - AddNote, - NoteAction(String, widget::text_editor::Action), - SaveNote(String, u64), WallpaperPicked(wallpaper_controls::Target, Result, String>), NewCommand(String), AddCommand, @@ -162,7 +158,6 @@ struct App { displays: Vec, display_selection: Option, display_error: Option, - note_editors: HashMap, core: Core, path: PathBuf, current: Snapshot, @@ -227,12 +222,6 @@ impl Drop for App { } } -struct NoteEditor { - content: widget::text_editor::Content, - revision: u64, - dirty: bool, -} - impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = (PathBuf, Result, Option); @@ -273,7 +262,6 @@ impl cosmic::Application for App { displays: vec![], display_selection: None, display_error: None, - note_editors: HashMap::new(), core, path, draft: current.clone(), @@ -322,7 +310,6 @@ impl cosmic::Application for App { .map(|id| app.set_window_title("Ferese Settings".into(), id)) .unwrap_or_else(Task::none); - app.sync_notes(); let connections = if app.page == Page::Connections { app.refresh_connections() } else { @@ -393,77 +380,16 @@ impl cosmic::Application for App { theme }; } - Message::NoteAction(id, action) => { - if let Some(editor) = self.note_editors.get_mut(&id) { - let edited = action.is_edit(); - editor.content.perform(action); - - if edited { - editor.dirty = true; - editor.revision = editor.revision.wrapping_add(1); - let revision = editor.revision; - return cosmic::task::future(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - Message::SaveNote(id, revision) - }); - } - } - } - Message::SaveNote(id, revision) => { - let Some(index) = self.note_index(&id) else { - return Task::none(); - }; - - if let Some(editor) = self.note_editors.get_mut(&id) - && editor.dirty - && editor.revision == revision - { - let value = editor.content.text(); - editor.dirty = false; - return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value)); - } - } - Message::AddNote if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => { - let id = format!( - "note-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let task = self.change(Edit::Add( - "desktop_widgets.notes".into(), - vec![ - ("id".into(), id.into()), - ("title".into(), "Note".into()), - ("text".into(), "".into()), - ], - )); - - self.sync_notes(); - return task; - } - Message::SelectFont(path, family) => { - self.inputs.remove(&path); - return self.change(set(&path, family)); - } - Message::DragWindow => return self.core.drag(None), Message::ExternalConfig(result) => match result { Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {} Ok(snapshot) => { - if self.saving - || self.note_editors.values().any(|editor| editor.dirty) - || !self.inputs.is_empty() - || !self.ranges.is_empty() - || !self.pending.is_empty() - { + if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() { self.status = "Config changed externally · Reload when your edits are finished".into(); } else { self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.sync_notes(); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family); self.undo = None; self.error = None; @@ -734,7 +660,6 @@ impl cosmic::Application for App { self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.sync_notes(); self.status = if live { "Saved · desktop updated" } else if self.path != store::config_path() { @@ -765,8 +690,6 @@ impl cosmic::Application for App { self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.note_editors.clear(); - self.sync_notes(); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family); self.pending.clear(); self.inputs.clear(); @@ -957,7 +880,6 @@ impl cosmic::Application for App { Message::Remove(table, index) if !self.saving => { self.inputs.clear(); let task = self.change(Edit::Remove(table, index)); - self.sync_notes(); return task; } _ => {} @@ -1021,33 +943,6 @@ impl App { self.native_palette = palette; cosmic::command::set_theme(palette.native_theme()) } - - fn note_index(&self, id: &str) -> Option { - (0..self.draft.records("desktop_widgets.notes")) - .find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id) - } - - fn sync_notes(&mut self) { - let mut ids = Vec::new(); - - for index in 0..self.draft.records("desktop_widgets.notes") { - let prefix = format!("desktop_widgets.notes.{index}"); - let id = self.draft.string(&format!("{prefix}.id"), "note"); - let text = self.draft.string(&format!("{prefix}.text"), ""); - let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor { - content: widget::text_editor::Content::with_text(&text), - revision: 0, - dirty: false, - }); - if !editor.dirty && editor.content.text() != text { - editor.content = widget::text_editor::Content::with_text(&text); - } - ids.push(id); - } - - self.note_editors.retain(|id, _| ids.contains(id)); - } - fn edit_many(&mut self, edits: Vec) -> Task { for edit in edits { if let Err(error) = self.draft.edit(&edit) { @@ -1770,46 +1665,6 @@ mod tests { assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl"); } - #[test] - fn notes_save_only_the_latest_revision_and_preserve_multiline_text() { - let mut app = app(); - app.draft - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "test".into()), ("text".into(), "first\nsecond".into())], - )) - .unwrap(); - app.sync_notes(); - let editor = app.note_editors.get_mut("test").unwrap(); - editor.dirty = true; - editor.revision = 2; - let _ = app.update(Message::SaveNote("test".into(), 1)); - assert!(!app.saving); - assert!(app.note_editors["test"].dirty); - let _ = app.update(Message::SaveNote("test".into(), 2)); - assert!(app.saving); - assert!(!app.note_editors["test"].dirty); - assert_eq!(app.draft.string("desktop_widgets.notes.0.text", ""), "first\nsecond"); - } - - #[test] - fn external_config_does_not_replace_an_unsaved_note() { - let mut app = app(); - app.draft - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "test".into())], - )) - .unwrap(); - app.sync_notes(); - app.note_editors.get_mut("test").unwrap().dirty = true; - let _ = app.update(Message::ExternalConfig(Snapshot::parse( - "animations {\n speed 0.5\n}\n".into(), - ))); - assert!(app.note_editors.contains_key("test")); - assert!(app.status.contains("externally")); - } - #[test] fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() { let mut app = app(); diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index e7e26f8..2d98909 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -284,49 +284,6 @@ impl App { )); body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); } - Page::Desktop => { - let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); - body = body.push(self.label("Sticky notes", 14.)); - body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through.")); - for index in 0..self.draft.records("desktop_widgets.notes") { - let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note"); - let mut group = column([]).spacing(8); - - for field in schema::note_fields(index) { - group = group.push(self.field(field)); - } - - if let Some(editor) = self.note_editors.get(&id) { - let id = id.clone(); - group = group.push( - widget::TextEditor::new(&editor.content) - .height(160) - .font(self.font) - .size(13.) - .on_action(move |action| Message::NoteAction(id.clone(), action)), - ); - } - group = group.push(self.settings_icon_button( - "Remove note", - "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", - can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)), - )); - body = body.push( - container(group) - .padding([7, 10]) - .class(visuals::surface(palette.card, 14.)), - ); - } - body = body.push( - self.settings_button( - "Add note", - "M12 5v14 M5 12h14", - (can_change_list && self.draft.records("desktop_widgets.notes") < 32) - .then_some(Message::AddNote), - false, - ), - ); - } Page::Startup => { body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 1c9b979..5c026d3 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -3,7 +3,6 @@ pub enum Page { Appearance, Accessibility, Wallpaper, - Desktop, Bar, Notifications, LockScreen, @@ -18,10 +17,7 @@ pub enum Page { impl Page { pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [ - ( - "Desktop", - &[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar], - ), + ("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]), ("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]), ( "System", @@ -64,11 +60,10 @@ impl Page { } #[cfg(test)] - pub const ALL: [Self; 14] = [ + pub const ALL: [Self; 13] = [ Self::Appearance, Self::Accessibility, Self::Wallpaper, - Self::Desktop, Self::Bar, Self::Notifications, Self::LockScreen, @@ -86,7 +81,6 @@ impl Page { Self::Appearance => "Appearance", Self::Accessibility => "Accessibility", Self::Wallpaper => "Wallpaper", - Self::Desktop => "Desktop widgets", Self::Bar => "Panel & Shell", Self::Notifications => "Notifications", Self::LockScreen => "Lock screen", @@ -105,7 +99,6 @@ impl Page { Self::Appearance => "A desktop that feels like yours.", Self::Accessibility => "Make the desktop easier to see and use.", Self::Wallpaper => "Set the scene for your workspace.", - Self::Desktop => "Clock and sticky notes on your desktop.", Self::Bar => "Arrange panel items and groups.", Self::Notifications => "Stay informed on your terms.", Self::LockScreen => "Your desktop, safely put away.", @@ -126,7 +119,6 @@ impl Page { "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01" } Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", - Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", @@ -162,7 +154,6 @@ impl Page { #[derive(Clone, Debug)] pub enum Kind { - Font, Toggle(bool), Range { default: f64, @@ -262,379 +253,6 @@ pub fn choice( ) -> Field { Field::new(path, label, description, Kind::Choice { default, choices }) } - -fn clock_fields() -> Vec { - vec![ - toggle( - "desktop_widgets.clock.enabled", - "Desktop clock", - "Behind windows and click-through.", - false, - ), - choice( - "desktop_widgets.clock.style", - "Clock style", - "Pixel stacks heavy, alternating-color digits; Minimal uses plain text.", - "pixel", - &[("pixel", "Pixel"), ("minimal", "Minimal")], - ), - toggle( - "desktop_widgets.clock.bold", - "Bold text", - "Use the font's bold weight.", - false, - ), - toggle( - "desktop_widgets.clock.show_date", - "Show date", - "A small date above the digits in Pixel; beneath the time in Minimal.", - true, - ), - toggle( - "desktop_widgets.clock.lowercase", - "Lowercase", - "Lowercase month, weekday and am/pm.", - true, - ), - text( - "desktop_widgets.clock.anchor", - "Position", - "top_left, top_center, top_right, center_left, center, center_right, bottom_left, bottom_center, bottom_right", - "top_left", - ), - Field::new( - "desktop_widgets.clock.font_family", - "Font", - "Choose an installed family or enter a name. Empty uses the style default.", - Kind::Font, - ), - text( - "desktop_widgets.clock.time_format", - "Time format", - "%H:%M for 24-hour; %-I:%M %p for 12-hour; add :%S for seconds.", - "%-I:%M %p", - ), - text( - "desktop_widgets.clock.date_format", - "Date format", - "%A, %-d %B for weekday and date; %-d %b for a short date.", - "%a, %b %-d", - ), - text( - "desktop_widgets.clock.time_zone", - "Time zone", - "Empty follows system time; otherwise an IANA name such as Africa/Lagos.", - "", - ), - text( - "desktop_widgets.clock.color", - "Time color", - "#RRGGBB or #RRGGBBAA; empty follows the theme.", - "", - ), - text( - "desktop_widgets.clock.date_color", - "Date color", - "Empty follows muted theme text.", - "", - ), - text( - "desktop_widgets.clock.background", - "Background", - "Empty is transparent; optionally #RRGGBB or #RRGGBBAA.", - "", - ), - range( - "desktop_widgets.clock.margin_x", - "Horizontal margin", - "Distance from left/right edge; ignored when horizontally centered.", - RangeSpec { - default: 64.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.margin_y", - "Vertical margin", - "Distance from top/bottom edge; ignored when vertically centered.", - RangeSpec { - default: 80.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.width", - "Widget width", - "Logical pixels; increase for longer formats.", - RangeSpec { - default: 440.0, - min: 64.0, - max: 1600.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.height", - "Widget height", - "Leave room for both text lines.", - RangeSpec { - default: 320.0, - min: 32.0, - max: 800.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.time_size", - "Time size", - "Logical pixels; scales with your output.", - RangeSpec { - default: 128.0, - min: 8.0, - max: 240.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.date_size", - "Date size", - "Separate size for the date line.", - RangeSpec { - default: 18.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.opacity", - "Opacity", - "Applies to text and the optional background.", - RangeSpec { - default: 0.9, - min: 0.0, - max: 1.0, - step: 0.05, - suffix: "", - integer: false, - }, - ), - range( - "desktop_widgets.clock.gap", - "Line spacing", - "Space between time and date.", - RangeSpec { - default: 4.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.padding", - "Inner padding", - "Space around the labels.", - RangeSpec { - default: 12.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - choice( - "desktop_widgets.clock.alignment", - "Text alignment", - "Alignment within the widget.", - "center", - &[("left", "Left"), ("center", "Center"), ("right", "Right")], - ), - ] -} - -pub fn note_fields(index: usize) -> Vec { - let prefix = format!("desktop_widgets.notes.{index}"); - vec![ - Field::new( - format!("{prefix}.interactive"), - "Desktop editing", - "Drag the title and edit directly. Off makes the card click-through.", - Kind::Toggle(true), - ), - Field::new(format!("{prefix}.enabled"), "Show note", "", Kind::Toggle(true)), - text(format!("{prefix}.title"), "Title", "Empty hides the title.", "Note"), - text( - format!("{prefix}.anchor"), - "Position", - "Nine positions, e.g. top_right or center.", - "top_right", - ), - Field::new( - format!("{prefix}.font_family"), - "Font", - "Choose an installed family or enter a name. Empty follows the desktop font.", - Kind::Font, - ), - text( - format!("{prefix}.color"), - "Text color", - "Empty or theme follows the theme; otherwise #RRGGBB / #RRGGBBAA.", - "", - ), - text( - format!("{prefix}.background"), - "Background", - "theme follows the palette; empty is transparent; otherwise hex color.", - "theme", - ), - range( - format!("{prefix}.width"), - "Width", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 320.0, - min: 120.0, - max: 1200.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.height"), - "Height", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 240.0, - min: 80.0, - max: 1200.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.margin_x"), - "Horizontal margin", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 48.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.margin_y"), - "Vertical margin", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 80.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.text_size"), - "Text size", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 16.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.title_size"), - "Title size", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 18.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.opacity"), - "Opacity", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 0.9, - min: 0.0, - max: 1.0, - step: 0.05, - suffix: "", - integer: false, - }, - ), - range( - format!("{prefix}.padding"), - "Padding", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 20.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.gap"), - "Title spacing", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 8.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - Field::new( - format!("{prefix}.alignment"), - "Text alignment", - "", - Kind::Choice { - default: "left", - choices: &[("left", "Left"), ("center", "Center"), ("right", "Right")], - }, - ), - ] -} - pub fn fields(page: Page) -> Vec { match page { Page::LockScreen => vec![ @@ -777,7 +395,7 @@ pub fn fields(page: Page) -> Vec { range( "theme.geometry.shell_radius", "Shell corner radius", - "Rounds the bar, menus, notifications, desktop widgets and hover backgrounds. Window corners are separate.", + "Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.", RangeSpec { default: 14.0, min: 0.0, @@ -875,7 +493,6 @@ pub fn fields(page: Page) -> Vec { &[("fill", "Fill"), ("fit", "Fit")], ), ], - Page::Desktop => clock_fields(), Page::Notifications => vec![ toggle( "notifications.show_popups", diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index a66f467..a263fc0 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -345,27 +345,11 @@ window-rule app-id="mine" floating=#true assert_eq!(snapshot.records("autostart"), 0); } - #[test] - fn nested_note_lists_keep_existing_clock_settings() { - let mut snapshot = - Snapshot::parse("desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into()).unwrap(); - snapshot - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "first".into()), ("text".into(), "one\ntwo".into())], - )) - .unwrap(); - assert_eq!(snapshot.records("desktop_widgets.notes"), 1); - assert!(snapshot.boolean("desktop_widgets.clock.enabled", false)); - assert_eq!(snapshot.string("desktop_widgets.notes.0.text", ""), "one\ntwo"); - snapshot.edit(&Edit::Remove("desktop_widgets.notes".into(), 0)).unwrap(); - assert_eq!(snapshot.records("desktop_widgets.notes"), 0); - } - #[test] #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] fn all_gui_controls_and_presets_pass_compositor_validation() { let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); + let initial = Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("config.kdl"); let validate = |snapshot: &Snapshot| { @@ -379,14 +363,10 @@ window-rule app-id="mine" floating=#true ); }; for page in crate::schema::Page::ALL { - let mut fields = crate::schema::fields(page); - if page == crate::schema::Page::Desktop { - fields.extend(crate::schema::note_fields(0)); - } + let fields = crate::schema::fields(page); for field in fields { use crate::schema::Kind; let values: Vec = match field.kind { - Kind::Font => vec!["".into(), "sans-serif".into()], Kind::Toggle(_) => vec![true.into(), false.into()], Kind::Range { min, max, integer, .. } => { if integer { @@ -399,22 +379,14 @@ window-rule app-id="mine" floating=#true Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(), }; for value in values { - let mut snapshot = Snapshot::parse(String::new()).unwrap(); - if field.path.starts_with("desktop_widgets.notes.") { - snapshot - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "test".into())], - )) - .unwrap(); - } + let mut snapshot = initial.clone(); snapshot.edit(&set(&field.path, value)).unwrap(); validate(&snapshot); } } } for preset in 0..crate::visuals::PRESETS.len() { - let mut snapshot = Snapshot::parse(String::new()).unwrap(); + let mut snapshot = initial.clone(); for edit in crate::visuals::preset(preset) { snapshot.edit(&edit).unwrap(); } diff --git a/shell/ferese-shell/src/clock.rs b/shell/ferese-shell/src/clock.rs index 89c1129..c184db5 100644 --- a/shell/ferese-shell/src/clock.rs +++ b/shell/ferese-shell/src/clock.rs @@ -5,98 +5,32 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::sync::Arc; use cosmic::iced::{Subscription, futures}; -use ferese_config::desktop::Clock; use jiff::{Timestamp, Zoned, tz::TimeZone}; const SECOND: i128 = 1_000_000_000; const MINUTE: i128 = 60 * SECOND; const UNNAMED_ZONE: &str = "ferese/unnamed-system-zone"; -// Fractional formats keep the previous sampling rate; nanosecond formats must -// not turn into a nanosecond timer that continuously rebuilds the shell. -const FRACTION_SAMPLE: i128 = SECOND / 2; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Labels { - pub bar: String, - pub desktop: (String, String), +pub fn current_time() -> String { + labels(Timestamp::now(), &zones().0) } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct Format { - time: String, - date: Option, - zone: Option, - lowercase: bool, -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct Settings { - bar: bool, - desktop: Option>, -} - -#[derive(Default)] -pub struct Service { - desktop: Option>, -} - -impl Service { - pub fn new(clock: &Clock) -> Self { - let mut service = Self::default(); - service.configure(clock); - service - } - - pub fn configure(&mut self, clock: &Clock) { - if !clock.enabled { - self.desktop = None; - return; - } - - let date = clock.show_date.then_some(clock.date_format.as_str()); - let zone = clock.time_zone.as_deref().filter(|zone| !zone.is_empty()); - if self.desktop.as_ref().is_some_and(|format| { - format.time == clock.time_format - && format.date.as_deref() == date - && format.zone.as_deref() == zone - && format.lowercase == clock.lowercase - }) { - return; - } - - self.desktop = Some(Arc::new(Format { - time: clock.time_format.clone(), - date: date.map(str::to_owned), - zone: zone.map(str::to_owned), - lowercase: clock.lowercase, - })); - } - - pub fn subscription(&self, bar: bool, desktop_visible: bool) -> Subscription { - self.settings(bar, desktop_visible) - .map_or_else(Subscription::none, |settings| Subscription::run_with(settings, stream)) - } - - fn settings(&self, bar: bool, desktop_visible: bool) -> Option { - let desktop = if desktop_visible { self.desktop.clone() } else { None }; - if !bar && desktop.is_none() { - return None; - } - - Some(Settings { bar, desktop }) +pub fn subscription(visible: bool) -> Subscription { + if visible { + Subscription::run_with((), stream) + } else { + Subscription::none() } } -fn stream(settings: &Settings) -> impl futures::Stream + use<> { - let settings = settings.clone(); +fn stream(_: &()) -> impl futures::Stream + use<> { let (send, receive) = tokio::sync::mpsc::channel(1); let stop = match UnixStream::pair() { Ok((stop, cancel)) => { match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || { - if let Err(error) = watch(&settings, send, cancel) { + if let Err(error) = watch(send, cancel) { eprintln!("ferese-shell: clock subscription: {error}"); } }) { @@ -118,22 +52,22 @@ fn stream(settings: &Settings) -> impl futures::Stream + use<> { }) } -fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender, cancel: UnixStream) -> io::Result<()> { +fn watch(send: tokio::sync::mpsc::Sender, cancel: UnixStream) -> io::Result<()> { let timer = owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?; - let (mut system, mut desktop, mut paths) = zones(settings); + let (mut system, mut paths) = zones(); let mut timezone = ZoneWatch::new(&paths)?; let mut previous = None; loop { let now = Timestamp::now(); - let labels = labels(settings, now, &system, &desktop); + let labels = labels(now, &system); if previous.as_ref() != Some(&labels) { previous = Some(labels.clone()); if send.blocking_send(labels).is_err() { return Ok(()); } } - if arm(&timer, deadline(settings, now, &system, &desktop))? { + if arm(&timer, zone_deadline(&now.to_zoned(system.clone())))? { continue; } let mut fds = [ @@ -170,7 +104,7 @@ fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender, cancel: U } if fds[1].revents != 0 && timezone.changed()? { jiff::tz::db().reset(); - (system, desktop, paths) = zones(settings); + (system, paths) = zones(); timezone = ZoneWatch::new(&paths)?; } } @@ -220,7 +154,7 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> { Ok(()) } -fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec) { +fn zones() -> (TimeZone, Vec) { let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")]; // Load the file directly: Jiff's system timezone cache lasts five minutes. let system = match std::env::var("TZ") { @@ -228,13 +162,7 @@ fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec) { Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system), Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system), }; - let desktop = settings - .desktop - .as_ref() - .and_then(|format| format.zone.as_ref()) - .and_then(|zone| named_zone(zone, &mut paths)) - .unwrap_or_else(|| system.clone()); - (system, desktop, paths) + (system, paths) } fn named_zone(name: &str, paths: &mut Vec) -> Option { @@ -270,93 +198,20 @@ fn file_zone(path: &Path, paths: &mut Vec) -> Option { Some(zone) } -fn labels(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> Labels { - let bar = if settings.bar { - ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone())) - } else { - String::new() - }; - let desktop = settings - .desktop - .as_ref() - .map(|format| { - let now = now.to_zoned(desktop.clone()); - let mut broken = jiff::fmt::strtime::BrokenDownTime::from(&now); - if desktop.iana_name() == Some(UNNAMED_ZONE) { - // Copied localtime files have no IANA name. Preserve Jiff's - // %Q offset fallback while retaining their DST/abbreviation. - broken.set_iana_time_zone(None); - } - let time = broken.to_string(&format.time).unwrap_or_default(); - let date = format - .date - .as_ref() - .map(|date| broken.to_string(date).unwrap_or_default()) - .unwrap_or_default(); - if format.lowercase { - (time.to_lowercase(), date.to_lowercase()) - } else { - (time, date) - } - }) - .unwrap_or_default(); - Labels { bar, desktop } +fn labels(now: Timestamp, system: &TimeZone) -> String { + ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone())) } -fn deadline(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> i128 { - let mut next = i128::MAX; - if settings.bar { - next = next.min(zone_deadline(&now.to_zoned(system.clone()), MINUTE)); - } - if let Some(format) = &settings.desktop { - let precision = - format_precision(&format.time).min(format.date.as_deref().map(format_precision).unwrap_or(MINUTE)); - next = next.min(zone_deadline(&now.to_zoned(desktop.clone()), precision)); - } - next -} - -fn zone_deadline(now: &Zoned, precision: i128) -> i128 { - let stamp = now.timestamp().as_nanosecond(); - let next = if precision == MINUTE { - stamp + MINUTE - i128::from(now.second()) * SECOND - i128::from(now.subsec_nanosecond()) - } else { - (stamp.div_euclid(precision) + 1) * precision - }; +fn zone_deadline(now: &Zoned) -> i128 { + let next = now.timestamp().as_nanosecond() + MINUTE + - i128::from(now.second()) * SECOND + - i128::from(now.subsec_nanosecond()); now.time_zone() .following(now.timestamp()) .next() .map_or(next, |transition| next.min(transition.timestamp().as_nanosecond())) } -fn format_precision(format: &str) -> i128 { - let mut precision = MINUTE; - let mut chars = format.chars(); - while let Some(character) = chars.next() { - if character != '%' { - continue; - } - let Some(mut directive) = chars.next() else { - break; - }; - if directive == '%' { - continue; - } - while matches!(directive, '_' | '-' | '0' | '^' | '#' | ':' | '.') || directive.is_ascii_digit() { - let Some(next) = chars.next() else { - return precision; - }; - directive = next; - } - precision = precision.min(match directive { - 'f' | 'N' => FRACTION_SAMPLE, - 'S' | 's' | 'T' | 'c' | 'r' | 'X' => SECOND, - _ => MINUTE, - }); - } - precision -} - struct ZoneWatch { fd: OwnedFd, names: Vec<(libc::c_int, OsString)>, @@ -433,142 +288,10 @@ impl ZoneWatch { mod tests { use super::*; use std::time::Duration; - - fn settings(time: Option<&str>, date: Option<&str>) -> Settings { - Settings { - bar: true, - desktop: time.map(|time| { - Arc::new(Format { - time: time.into(), - date: date.map(str::to_owned), - zone: None, - lowercase: false, - }) - }), - } - } - #[test] - fn subscription_rebuilds_and_style_changes_keep_the_cached_format() { - let mut clock = Clock { - enabled: true, - time_zone: Some("Asia/Kathmandu".into()), - ..Clock::default() - }; - let mut service = Service::new(&clock); - let first = service.settings(true, true).unwrap(); - let format = first.desktop.as_ref().unwrap(); - for _ in 0..100 { - let current = service.settings(true, true).unwrap(); - assert!(Arc::ptr_eq(format, current.desktop.as_ref().unwrap())); - assert_eq!(first, current); - } - - clock.time_size += 8.; - clock.margin_x += 10; - service.configure(&clock); - assert!(Arc::ptr_eq(format, service.desktop.as_ref().unwrap())); - assert!(service.settings(true, false).unwrap().desktop.is_none()); - assert!(service.settings(false, false).is_none()); - assert!(Arc::ptr_eq( - format, - service.settings(false, true).unwrap().desktop.as_ref().unwrap() - )); - } - - #[test] - fn format_changes_replace_subscription_identity_without_mutating_the_worker_settings() { - let mut clock = Clock { - enabled: true, - ..Clock::default() - }; - let mut service = Service::new(&clock); - let initial = service.settings(true, true).unwrap(); - clock.time_format = "%H:%M:%S".into(); - service.configure(&clock); - let seconds = service.settings(true, true).unwrap(); - assert_ne!(initial, seconds); - assert_eq!(initial.desktop.as_ref().unwrap().time, "%-I:%M %p"); - - clock.show_date = false; - service.configure(&clock); - let no_date = service.settings(true, true).unwrap(); - assert_ne!(seconds, no_date); - assert!(no_date.desktop.as_ref().unwrap().date.is_none()); - clock.date_format = "%Y".into(); - service.configure(&clock); - assert!(Arc::ptr_eq( - no_date.desktop.as_ref().unwrap(), - service.desktop.as_ref().unwrap() - )); - - clock.time_zone = Some("UTC".into()); - service.configure(&clock); - assert_ne!(no_date, service.settings(true, true).unwrap()); - clock.lowercase = !clock.lowercase; - let previous = service.settings(true, true).unwrap(); - service.configure(&clock); - assert_ne!(previous, service.settings(true, true).unwrap()); - clock.enabled = false; - service.configure(&clock); - assert!(service.settings(false, true).is_none()); - assert!(service.settings(true, true).unwrap().desktop.is_none()); - clock.enabled = true; - service.configure(&clock); - assert!(service.settings(false, true).unwrap().desktop.is_some()); - } - - #[test] - fn production_subscription_uses_visible_outputs_formats_and_hidden_date() { - let mut clock = Clock { - enabled: true, - time_format: "%H:%M".into(), - date_format: "%S%.f".into(), - ..Clock::default() - }; - assert_eq!(Service::new(&clock).subscription(false, false).units(), 0); - assert_eq!(Service::new(&clock).subscription(true, false).units(), 1); - let hidden = Service::new(&clock).settings(true, false).unwrap(); - assert!(hidden.desktop.is_none()); - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - assert_eq!( - deadline(&hidden, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond() - ); - let visible = Service::new(&clock).settings(true, true).unwrap(); - assert_eq!( - deadline(&visible, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:34:56.5Z".parse::().unwrap().as_nanosecond() - ); - clock.show_date = false; - let hidden_date = Service::new(&clock).settings(true, true).unwrap(); - assert_ne!(hidden_date, visible); - assert_eq!( - deadline(&hidden_date, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond() - ); - clock.time_format = "%S".into(); - assert_ne!(Service::new(&clock).settings(true, true).unwrap(), hidden_date); - clock.enabled = false; - assert_eq!(Service::new(&clock).subscription(false, true).units(), 0); - } - - #[test] - fn named_zone_labels_match_configured_clock_including_aliases_and_case() { - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - for zone in ["America/New_York", "Etc/UTC", "Asia/Kathmandu", "UTC", "utc"] { - let clock = Clock { - enabled: true, - time_format: "%H:%M:%S%.3f %Q %Z".into(), - date_format: "%a %F".into(), - time_zone: Some(zone.into()), - ..Clock::default() - }; - let settings = Service::new(&clock).settings(true, true).unwrap(); - let desktop = named_zone(zone, &mut Vec::new()).unwrap_or_else(|| panic!("zone {zone} unavailable")); - let actual = labels(&settings, now, &TimeZone::UTC, &desktop).desktop; - assert_eq!(actual, clock.labels(&now.to_zoned(TimeZone::UTC)).unwrap(), "{zone}"); - } + fn panel_subscription_stops_when_all_panels_are_hidden() { + assert_eq!(subscription(false).units(), 0); + assert_eq!(subscription(true).units(), 1); } #[test] @@ -590,54 +313,31 @@ mod tests { std::fs::rename(replacement, &path).unwrap(); assert!(watcher.changed().unwrap()); let second = file_zone(&path, &mut Vec::new()).unwrap(); - let bar_settings = settings(None, None); - assert_eq!(labels(&bar_settings, now, &first, &first).bar, "4 oct, 8:34 am"); - assert_eq!(labels(&bar_settings, now, &second, &second).bar, "4 oct, 6:19 pm"); - let settings = settings(Some("%Q %:Q %Z %z"), None); - assert_eq!( - labels(&settings, now, &first, &first).desktop.0, - "-0400 -04:00 EDT -0400" - ); - assert_eq!( - labels(&settings, now, &second, &second).desktop.0, - "+0545 +05:45 +0545 +0545" - ); + assert_eq!(labels(now, &first), "4 oct, 8:34 am"); + assert_eq!(labels(now, &second), "4 oct, 6:19 pm"); } #[test] - fn visible_format_precision_handles_seconds_escapes_and_sampled_fractions() { - for format in ["%-I:%M %p", "%a, %b %-d", "%%S %%f", "%:z %:::z", "fixed text"] { - assert_eq!(format_precision(format), MINUTE, "{format}"); + fn realtime_jumps_recompute_boundaries_and_timezone_labels() { + let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap(); + let first = labels(initial, &TimeZone::UTC); + for (stamp, expected) in [ + ("2026-10-04T11:20:01Z", "2026-10-04T11:21:00Z"), + ("2026-10-04T15:40:01Z", "2026-10-04T15:41:00Z"), + ] { + let now: Timestamp = stamp.parse().unwrap(); + assert_ne!(labels(now, &TimeZone::UTC), first); + assert_eq!( + zone_deadline(&now.to_zoned(TimeZone::UTC)), + expected.parse::().unwrap().as_nanosecond() + ); } - for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] { - assert_eq!(format_precision(format), SECOND, "{format}"); - } - for format in ["%f", "%N", "%.f", "%.3f", "%9f", "%1f"] { - assert_eq!(format_precision(format), FRACTION_SAMPLE, "{format}"); - } - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - let minute = "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond(); + let zone = TimeZone::get("Asia/Kathmandu").unwrap(); + assert_ne!(labels(initial, &zone), first); + assert_eq!(labels(initial, &zone), "4 oct, 6:19 pm"); assert_eq!( - deadline(&settings(None, None), now, &TimeZone::UTC, &TimeZone::UTC), - minute - ); - assert_eq!( - deadline( - &settings(Some("%H:%M"), Some("%S")), - now, - &TimeZone::UTC, - &TimeZone::UTC - ), - "2026-10-04T12:34:57Z".parse::().unwrap().as_nanosecond() - ); - assert_eq!( - deadline( - &settings(Some("%H:%M:%S%.3f"), None), - now, - &TimeZone::UTC, - &TimeZone::UTC - ), - "2026-10-04T12:34:56.5Z".parse::().unwrap().as_nanosecond() + labels(initial, &TimeZone::UTC), + labels(initial + jiff::SignedDuration::from_secs(1), &TimeZone::UTC) ); } @@ -649,7 +349,7 @@ mod tests { ] { let now: Timestamp = stamp.parse().unwrap(); assert_eq!( - zone_deadline(&now.to_zoned(TimeZone::UTC), MINUTE), + zone_deadline(&now.to_zoned(TimeZone::UTC)), expected.parse::().unwrap().as_nanosecond() ); } @@ -660,48 +360,17 @@ mod tests { ] { let now: Timestamp = stamp.parse().unwrap(); let expected: Timestamp = expected.parse().unwrap(); - assert_eq!( - zone_deadline(&now.to_zoned(zone.clone()), MINUTE), - expected.as_nanosecond() - ); - assert_eq!(labels(&settings(None, None), expected, &zone, &zone).bar, label); + assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond()); + assert_eq!(labels(expected, &zone), label); } let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap()); let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap(); assert_eq!( - zone_deadline(&now.to_zoned(zone), MINUTE), + zone_deadline(&now.to_zoned(zone)), "2026-10-04T12:34:30Z".parse::().unwrap().as_nanosecond() ); } - #[test] - fn realtime_jumps_recompute_boundaries_and_timezone_labels() { - let settings = settings(Some("%H:%M %Z"), None); - let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap(); - let earlier: Timestamp = "2026-10-04T11:20:01Z".parse().unwrap(); - let later: Timestamp = "2026-10-04T15:40:01Z".parse().unwrap(); - let first = labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC); - for (now, expected) in [(earlier, "2026-10-04T11:21:00Z"), (later, "2026-10-04T15:41:00Z")] { - assert_ne!(labels(&settings, now, &TimeZone::UTC, &TimeZone::UTC), first); - assert_eq!( - deadline(&settings, now, &TimeZone::UTC, &TimeZone::UTC), - expected.parse::().unwrap().as_nanosecond() - ); - } - let zone = TimeZone::get("Asia/Kathmandu").unwrap(); - assert_ne!(labels(&settings, initial, &zone, &zone), first); - assert_eq!(labels(&settings, initial, &zone, &zone).desktop.0, "18:19 +0545"); - assert_eq!( - labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC), - labels( - &settings, - initial + jiff::SignedDuration::from_secs(1), - &TimeZone::UTC, - &TimeZone::UTC - ) - ); - } - #[test] fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() { let dir = tempfile::tempdir().unwrap(); @@ -739,7 +408,7 @@ mod tests { let (send, mut receive) = tokio::sync::mpsc::channel(1); let (done_send, done_receive) = std::sync::mpsc::channel(); std::thread::spawn(move || { - done_send.send(watch(&settings(None, None), send, cancel)).unwrap(); + done_send.send(watch(send, cancel)).unwrap(); }); assert!(receive.blocking_recv().is_some()); drop(stop); diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 3e06b50..3e9ef14 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -8,7 +8,6 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug)] pub(crate) struct ShellConfig { pub(crate) notifications: ferese_config::notifications::NotificationConfig, - pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets, pub(crate) animations: crate::motion::Settings, pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, @@ -23,7 +22,6 @@ impl Default for ShellConfig { let status = StatusConfig::default(); Self { notifications: Default::default(), - desktop_widgets: Default::default(), animations: Default::default(), font_family: None, wallpaper: Default::default(), @@ -186,8 +184,6 @@ struct FereseConfig { #[serde(default)] notifications: ferese_config::notifications::NotificationConfig, #[serde(default)] - desktop_widgets: ferese_config::desktop::DesktopWidgets, - #[serde(default)] animations: crate::motion::Settings, #[serde(default)] theme: ThemeConfig, @@ -380,7 +376,6 @@ fn parse_document( config.animations.validate().map_err(ferese_config::Error::from)?; config.notifications.validate().map_err(ferese_config::Error::from)?; - config.desktop_widgets.validate().map_err(ferese_config::Error::from)?; let mut theme = shell_theme(&config.theme); theme.appearance = appearance; theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0); @@ -388,7 +383,6 @@ fn parse_document( Ok(ShellConfig { notifications: config.notifications, - desktop_widgets: config.desktop_widgets, animations: config.animations, font_family: config.theme.typography.font_family, wallpaper: config.theme.background, @@ -625,30 +619,15 @@ mod tests { } } - #[test] - fn desktop_clock_parses_and_rejects_invalid_reload_values() { - let clock = parse_test_source( - r#"desktop-widgets { - clock { - enabled #true - anchor "bottom_right" - font-family "" - color "" - time-zone "" - } -} -"#, - ) - .unwrap() - .desktop_widgets - .clock; - assert!(clock.enabled); - assert_eq!(clock.anchor, ferese_config::desktop::Anchor::BottomRight); - assert!(parse_test_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err()); - assert!(parse_test_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err()); - } use super::*; + #[test] + fn removed_desktop_widgets_are_rejected_at_startup_and_reload() { + let source = "desktop-widgets { clock { enabled #true; }; }"; + assert!(parse_source(source).is_err()); + assert!(parse_test_source(source).is_err()); + } + fn parse_test_source(source: &str) -> Result { let document = ferese_config::Document::parse(source)?; parse_document(&document, Default::default(), Default::default()) diff --git a/shell/ferese-shell/src/desktop_widgets.rs b/shell/ferese-shell/src/desktop_widgets.rs deleted file mode 100644 index a3e4b01..0000000 --- a/shell/ferese-shell/src/desktop_widgets.rs +++ /dev/null @@ -1,738 +0,0 @@ -use super::{ - Anchor, Background, Border, Color, Element, FereseShell, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, - Length, Limits, Message, NoteDrag, SctkLayerSurfaceSettings, Task, Zoned, alignment, button, color, config, - configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor, - set_margin, stacked_clock_digits, text, theme, window, -}; -use cosmic::iced::border::Shape as BorderShape; - -impl FereseShell { - pub(super) fn flush_note_changes(&mut self) -> Task { - if self.note_saving || self.note_pending.is_empty() { - return Task::none(); - } - - let Some(path) = config::config_path() else { - self.note_error = Some("Configuration path unavailable.".into()); - return Task::none(); - }; - self.note_saving = true; - self.note_inflight = std::mem::take(&mut self.note_pending); - let edits = self.note_inflight.clone(); - cosmic::task::future(async move { - let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); - std::thread::spawn(move || { - let _ = send.send(note_store::save(&path, &edits)); - }); - Message::NotesStored( - receive - .await - .unwrap_or_else(|_| Err("Note save worker stopped.".into())), - ) - }) - } - - pub(super) fn set_note_text(&mut self, id: &str, value: String) -> Task { - if let Some(note) = self.config.desktop_widgets.notes.iter_mut().find(|note| note.id == id) - && (note.text != value || self.note_error.is_some()) - { - note.text = value.clone(); - self.note_pending.push(note_store::Edit::Text(id.to_owned(), value)); - } - self.flush_note_changes() - } - - pub(super) fn finish_note_edit(&mut self) -> Task { - if let Some(editor) = self.note_editor.take() { - self.set_note_text(&editor.id, editor.content.text()) - } else { - self.flush_note_changes() - } - } - - pub(super) fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task { - self.begin_widget_drag(Some(id), surface) - } - - pub(super) fn begin_widget_drag(&mut self, id: Option, surface: window::Id) -> Task { - if self.note_drag.is_some() { - return Task::none(); - } - let Some(entry) = self - .outputs - .iter() - .find(|entry| entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface)) - else { - return Task::none(); - }; - let Some(size) = entry.size else { - return Task::none(); - }; - let (origin, dimensions) = if let Some(id) = &id { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| ¬e.id == id) else { - return Task::none(); - }; - (note_origin(note, size), (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - ( - widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ), - (clock.width, clock.height), - ) - }; - let output = entry.output.clone(); - let mut obstacles = Vec::new(); - let clock = &self.config.desktop_widgets.clock; - - if id.is_some() && clock.on_output(entry.name.as_deref()) { - let position = widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ); - obstacles.push(cosmic::iced::Rectangle::new( - position, - cosmic::iced::Size::new(clock.width as f32, clock.height as f32), - )); - } - - for note in &self.config.desktop_widgets.notes { - if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { - obstacles.push(cosmic::iced::Rectangle::new( - note_origin(note, size), - cosmic::iced::Size::new(note.width as f32, note.height as f32), - )); - } - } - - let overlay = window::Id::unique(); - self.note_drag = Some(NoteDrag { - id, - source: surface, - overlay, - origin, - position: origin, - start: self.note_pointer.get(&surface).copied().unwrap_or_default(), - output_size: size, - obstacles, - }); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: overlay, - layer: Layer::Overlay, - keyboard_interactivity: KeyboardInteractivity::None, - input_zone: Some(Vec::new()), - anchor: Anchor::TOP | Anchor::LEFT, - margin: IcedMargin { - top: origin.y.round() as i32, - left: origin.x.round() as i32, - ..Default::default() - }, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-note-drag".into(), - exclusive_zone: -1, - size: Some((Some(dimensions.0), Some(dimensions.1))), - size_limits: Limits::NONE, - }, - Some(Box::new(Self::view_note_drag)), - ); - - Task::batch([ - self.finish_note_edit(), - cosmic::task::message(cosmic::Action::Surface(action)), - ]) - } - - pub(super) fn view_note_drag(&self) -> Element<'_, cosmic::Action> { - let Some(drag) = &self.note_drag else { - return text("").into(); - }; - - if let Some(id) = &drag.id { - self.view_note_content(id, drag.source) - } else { - self.view_clock_content() - } - } - - pub(super) fn finish_note_drag(&mut self, save: bool) -> Task { - let Some(drag) = self.note_drag.take() else { - return Task::none(); - }; - let mut tasks = vec![destroy_layer_surface(drag.overlay)]; - - if save { - if drag.id.is_none() { - let clock = &mut self.config.desktop_widgets.clock; - clock.anchor = ferese_config::desktop::Anchor::TopLeft; - clock.margin_x = drag.position.x.round() as i32; - clock.margin_y = drag.position.y.round() as i32; - for entry in &self.outputs { - if let Some(id) = entry.clock { - tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x)); - } - } - self.note_pending - .push(note_store::Edit::ClockPosition(clock.margin_x, clock.margin_y)); - } - - if let Some(note) = self - .config - .desktop_widgets - .notes - .iter_mut() - .find(|note| Some(¬e.id) == drag.id.as_ref()) - { - note.anchor = ferese_config::desktop::Anchor::TopLeft; - note.margin_x = drag.position.x.round() as i32; - note.margin_y = drag.position.y.round() as i32; - - for entry in &self.outputs { - if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) { - tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); - } - } - - self.note_pending.push(note_store::Edit::Position( - note.id.clone(), - note.margin_x, - note.margin_y, - )); - } - - tasks.push(self.flush_note_changes()); - } - Task::batch(tasks) - } - - pub(super) fn rebuild_notes(&mut self, reset: bool) -> Task { - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if reset { - for (_, id) in entry.notes.drain(..) { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - } - - for note in &self.config.desktop_widgets.notes { - if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) { - continue; - } - - let id = window::Id::unique(); - entry.notes.push((note.id.clone(), id)); - let note = note.clone(); - let note_id = note.id.clone(); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = widget_placement(note.anchor, note.margin_x, note.margin_y); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: if note.interactive { - KeyboardInteractivity::OnDemand - } else { - KeyboardInteractivity::None - }, - input_zone: if note.interactive { None } else { Some(Vec::new()) }, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-sticky-note".into(), - exclusive_zone: -1, - size: Some((Some(note.width), Some(note.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_note(¬e_id, id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - } - - Task::batch(tasks) - } - - pub(super) fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.as_deref() == Some(id)) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - self.view_note_content(id, surface) - } - - pub(super) fn view_note_content(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) else { - return text("").into(); - }; - let alignment = match note.alignment { - ferese_config::desktop::Alignment::Left => alignment::Horizontal::Left, - ferese_config::desktop::Alignment::Center => alignment::Horizontal::Center, - ferese_config::desktop::Alignment::Right => alignment::Horizontal::Right, - }; - let font = configured_font( - note.font_family - .as_deref() - .filter(|s| !s.trim().is_empty()) - .or(self.config.font_family.as_deref()), - ); - let mut foreground = color( - note.color - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.text_primary), - ); - - foreground.a *= note.opacity; - - let background = if note.background.as_deref() == Some("") { - None - } else { - let mut tint = color( - note.background - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.surface_base), - ); - tint.a *= note.opacity; - Some(Background::Color(tint)) - }; - let mut body = cosmic::widget::column([]).spacing(note.gap).align_x(alignment); - - if note.interactive { - let title = ferese_theme::text(if note.title.is_empty() { "⋮⋮" } else { ¬e.title }, font) - .size(note.title_size) - .class(theme::Text::Color(foreground)); - let header = cosmic::widget::mouse_area(container(title).width(Length::Fill)) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface))); - let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id); - - body = body.push( - row([]).push(header).push(motion::button( - button::custom(text(if editing { "Done" } else { "Edit" })) - .padding([4, 8]) - .on_press(cosmic::Action::App(if editing { - Message::FinishNoteEdit - } else { - Message::BeginNoteEdit(id.to_owned()) - })), - foreground, - false, - 1.0, - )), - ); - } else if !note.title.is_empty() { - body = body.push( - ferese_theme::text( - note.title.clone(), - cosmic::font::Font { - weight: cosmic::iced::font::Weight::Semibold, - ..font - }, - ) - .size(note.title_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(editor) = &self.note_editor - && editor.id == id - { - let id = id.to_owned(); - let editor_radius = ferese_theme::inner_radius(self.config.theme.material_radius, note.padding); - body = body.push( - cosmic::widget::TextEditor::new(&editor.content) - .style(move |theme, status| { - use cosmic::iced::widget::text_editor::Catalog; - let mut style = theme.style(&::default(), status); - style.border.radius = editor_radius.into(); - style - }) - .height(Length::Fill) - .font(font) - .size(note.text_size) - .on_action(move |action| cosmic::Action::App(Message::NoteAction(id.clone(), action))), - ); - } else { - body = body.push( - ferese_theme::text(note.text.clone(), font) - .size(note.text_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(error) = &self.note_error { - body = body.push(text(error.clone()).size(10).class(theme::Text::Color(foreground))); - } - - let radius = self.config.theme.material_radius; - - container(body) - .padding(note.padding) - .width(Length::Fill) - .height(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - shape: BorderShape::Continuous, - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } - - pub(super) fn rebuild_clocks(&mut self, reset: bool) -> Task { - self.desktop_clock = self - .config - .desktop_widgets - .clock - .labels(&Zoned::now()) - .unwrap_or_default(); - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if !reset && entry.clock.is_some() { - continue; - } - - if let Some(id) = entry.clock.take() { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - - let clock = self.config.desktop_widgets.clock.clone(); - - if !clock.on_output(entry.name.as_deref()) { - continue; - } - - let id = window::Id::unique(); - entry.clock = Some(id); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = clock_placement(&clock); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: KeyboardInteractivity::OnDemand, - input_zone: None, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-desktop-clock".into(), - exclusive_zone: -1, - size: Some((Some(clock.width), Some(clock.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_desktop_clock(id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - - Task::batch(tasks) - } - - pub(super) fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.is_none() && drag.source == id) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - cosmic::widget::mouse_area(self.view_clock_content()) - .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .into() - } - - pub(super) fn view_clock_content(&self) -> Element<'_, cosmic::Action> { - use ferese_config::desktop::Alignment as ClockAlignment; - let clock = &self.config.desktop_widgets.clock; - let alignment = match clock.alignment { - ClockAlignment::Left => alignment::Horizontal::Left, - ClockAlignment::Center => alignment::Horizontal::Center, - ClockAlignment::Right => alignment::Horizontal::Right, - }; - let pixel = clock.style == ferese_config::desktop::ClockStyle::Pixel; - let custom_font = clock.font_family.as_deref().filter(|family| !family.trim().is_empty()); - let mut font = configured_font(if pixel { - custom_font.or(Some("Cantarell")) - } else { - custom_font.or(self.config.font_family.as_deref()) - }); - if pixel && custom_font.is_none() { - font.weight = cosmic::iced::font::Weight::Black; - } else if clock.bold { - font.weight = cosmic::iced::font::Weight::Bold; - } - let date_font = configured_font(custom_font.or(self.config.font_family.as_deref())); - let tint = |custom: &Option, fallback| { - let mut tint = color(custom.as_deref().and_then(config::parse_color).unwrap_or(fallback)); - tint.a *= clock.opacity; - tint - }; - let foreground = tint(&clock.color, self.config.theme.text_primary); - let accent = color(self.config.theme.accent); - let custom_color = clock.color.as_deref().and_then(config::parse_color).is_some(); - let digit_colors = [0.8, 0.45].map(|text_mix| { - if custom_color { - return foreground; - } - Color { - r: foreground.r * text_mix + accent.r * (1.0 - text_mix), - g: foreground.g * text_mix + accent.g * (1.0 - text_mix), - b: foreground.b * text_mix + accent.b * (1.0 - text_mix), - a: foreground.a, - } - }); - let mut labels = cosmic::widget::column([]).spacing(clock.gap).align_x(alignment); - - if pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), date_font) - .size(clock.date_size) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_primary, - ))), - ); - } - - if let Some(digits) = pixel.then(|| stacked_clock_digits(&self.desktop_clock.0)).flatten() { - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(16.0); - let size = clock - .time_size - .min(available_height / 1.6) - .min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5); - let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment); - - for (line, digits) in digits.into_iter().enumerate() { - let mut digit_row = row([]).spacing(0); - - for (position, digit) in digits.chars().enumerate() { - digit_row = digit_row.push( - ferese_theme::text(digit.to_string(), font) - .size(size) - .line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8)) - .class(theme::Text::Color(digit_colors[(line + position) % 2])), - ); - } - - stack = stack.push(digit_row); - } - - labels = labels.push(stack); - } else { - let characters = self.desktop_clock.0.chars().count().max(1) as f32; - let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0); - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(1.0); - let size = clock - .time_size - .min(available_width / (characters * 0.75)) - .min(available_height / 1.3); - - labels = labels.push( - ferese_theme::text(self.desktop_clock.0.clone(), font) - .size(size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if !pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), font) - .size(clock.date_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_muted, - ))), - ); - } - let background = clock.background.as_deref().and_then(config::parse_color).map(|rgba| { - let mut tint = color(rgba); - tint.a *= clock.opacity; - Background::Color(tint) - }); - let radius = self.config.theme.material_radius; - - container(labels) - .padding(clock.padding) - .width(Length::Fill) - .height(Length::Fill) - .align_x(alignment) - .center_y(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - shape: BorderShape::Continuous, - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } -} - -/// Sweep each axis, allowing edge sliding without tunnelling through a widget -/// when pointer events skip ahead. Coordinates match integer layer margins. -pub(super) fn avoid_widget_overlap( - previous: cosmic::iced::Point, - requested: cosmic::iced::Point, - size: (u32, u32), - obstacles: &[cosmic::iced::Rectangle], -) -> cosmic::iced::Point { - let mut position = previous; - let (width, height) = (size.0 as f32, size.1 as f32); - let mut x = requested.x.round(); - for obstacle in obstacles { - if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y { - if x > position.x && position.x + width <= obstacle.x { - x = x.min((obstacle.x - width).floor()); - } else if x < position.x && position.x >= obstacle.x + obstacle.width { - x = x.max((obstacle.x + obstacle.width).ceil()); - } - } - } - position.x = x; - let mut y = requested.y.round(); - for obstacle in obstacles { - if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x { - if y > position.y && position.y + height <= obstacle.y { - y = y.min((obstacle.y - height).floor()); - } else if y < position.y && position.y >= obstacle.y + obstacle.height { - y = y.max((obstacle.y + obstacle.height).ceil()); - } - } - } - position.y = y; - position -} - -pub(super) fn clamp_note_position( - position: cosmic::iced::Point, - output: (i32, i32), - size: (u32, u32), -) -> cosmic::iced::Point { - cosmic::iced::Point::new( - position.x.clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)), - position.y.clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)), - ) -} - -pub(super) fn note_origin(note: &ferese_config::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point { - widget_origin( - note.anchor, - note.margin_x, - note.margin_y, - (note.width, note.height), - output, - ) -} - -pub(super) fn widget_origin( - anchor: ferese_config::desktop::Anchor, - margin_x: i32, - margin_y: i32, - size: (u32, u32), - output: (i32, i32), -) -> cosmic::iced::Point { - use ferese_config::desktop::Anchor as A; - let x = match anchor { - A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32, - A::TopRight | A::CenterRight | A::BottomRight => output.0 as f32 - size.0 as f32 - margin_x as f32, - _ => (output.0 as f32 - size.0 as f32) / 2., - }; - let y = match anchor { - A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32, - A::BottomLeft | A::BottomCenter | A::BottomRight => output.1 as f32 - size.1 as f32 - margin_y as f32, - _ => (output.1 as f32 - size.1 as f32) / 2., - }; - clamp_note_position(cosmic::iced::Point::new(x, y), output, size) -} - -pub(super) fn clock_placement(clock: &ferese_config::desktop::Clock) -> (Anchor, IcedMargin) { - widget_placement(clock.anchor, clock.margin_x, clock.margin_y) -} - -pub(super) fn widget_placement( - position: ferese_config::desktop::Anchor, - margin_x: i32, - margin_y: i32, -) -> (Anchor, IcedMargin) { - use ferese_config::desktop::Anchor as Position; - let horizontal = match position { - Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT, - Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT, - _ => Anchor::empty(), - }; - let vertical = match position { - Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP, - Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM, - _ => Anchor::empty(), - }; - ( - horizontal | vertical, - IcedMargin { - top: if vertical == Anchor::TOP { margin_y } else { 0 }, - bottom: if vertical == Anchor::BOTTOM { margin_y } else { 0 }, - left: if horizontal == Anchor::LEFT { margin_x } else { 0 }, - right: if horizontal == Anchor::RIGHT { margin_x } else { 0 }, - }, - ) -} - -pub(super) fn current_time() -> String { - format_bar_time(&Zoned::now()) -} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 3466ba0..741a557 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,9 +1,7 @@ mod bar; -mod desktop_widgets; mod surfaces; use bar::*; -use desktop_widgets::*; use surfaces::*; mod clock; @@ -14,7 +12,6 @@ mod display_mode; mod keybinding_guide; mod media; mod motion; -mod note_store; mod notification_ui; mod notifications; mod panel; @@ -46,9 +43,7 @@ use cosmic::{Element, theme}; use ferese_protocols::effects::v1::client::ferese_effects_manager_v1::FereseEffectsManagerV1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1::FereseSurfaceEffectsV1; -use ferese_theme::calendar::{format_bar_time, stacked_digits as stacked_clock_digits}; use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon}; -use jiff::Zoned; use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::{wl_output, wl_registry, wl_surface}; use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop}; @@ -121,19 +116,6 @@ fn main() -> cosmic::iced::Result { }); renderer::configure_shell(); - cosmic::iced::advanced::graphics::text::font_system() - .write() - .unwrap() - .load_font(std::borrow::Cow::Borrowed(include_bytes!( - "../../../assets/fonts/Comfortaa-Regular.otf" - ))); - cosmic::iced::advanced::graphics::text::font_system() - .write() - .unwrap() - .load_font(std::borrow::Cow::Borrowed(include_bytes!( - "../../../assets/fonts/Cantarell-ExtraBold.otf" - ))); - motion::configure(config.animations, config.theme.material_radius); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); if compositor_wallpaper { @@ -177,8 +159,6 @@ struct FereseShell { workspace_ui: workspace_ui::WorkspaceUi, overview_active: bool, clock: String, - clock_service: clock::Service, - desktop_clock: (String, String), outputs: Vec, notifications: notifications::Center, notification_surface: Option, @@ -196,30 +176,6 @@ struct FereseShell { guide_load: keybinding_guide::LoadState, guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, - note_editor: Option, - note_drag: Option, - note_pointer: std::collections::HashMap, - note_pending: Vec, - note_inflight: Vec, - note_saving: bool, - note_error: Option, -} - -struct DesktopNoteEditor { - id: String, - content: cosmic::widget::text_editor::Content, - revision: u64, -} - -struct NoteDrag { - id: Option, - source: window::Id, - overlay: window::Id, - start: cosmic::iced::Point, - origin: cosmic::iced::Point, - position: cosmic::iced::Point, - output_size: (i32, i32), - obstacles: Vec, } struct OutputSurfaces { @@ -227,8 +183,6 @@ struct OutputSurfaces { name: Option, bar: window::Id, wallpaper: Option, - clock: Option, - notes: Vec<(String, window::Id)>, size: Option<(i32, i32)>, effects: Option, bar_regions: Vec<[f32; 5]>, @@ -244,17 +198,10 @@ enum Message { ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), ThemeModeSet(Result<(), String>), - BeginNoteEdit(String), - NoteAction(String, cosmic::widget::text_editor::Action), - SaveNote(String, u64), - FinishNoteEdit, - BeginNoteDrag(String, window::Id), - BeginClockDrag(window::Id), - NotesStored(Result<(), String>), WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>), - ClockChanged(clock::Labels), + ClockChanged(String), RecorderEvent(recording::Event), RecorderElapsed, MediaUpdated(std::sync::Arc), @@ -330,7 +277,6 @@ impl cosmic::Application for FereseShell { } fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { - let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default(); let app = Self { core, notifications: notifications::Center::new(config.notifications.clone()), @@ -349,14 +295,6 @@ impl cosmic::Application for FereseShell { guide_load: Default::default(), guide_attempts: 0, pending_power: None, - note_editor: None, - note_drag: None, - note_pointer: Default::default(), - note_pending: Vec::new(), - note_inflight: Vec::new(), - note_saving: false, - note_error: None, - clock_service: clock::Service::new(&config.desktop_widgets.clock), config, wallpaper: None, control: ShellControl::connect() @@ -367,8 +305,7 @@ impl cosmic::Application for FereseShell { snapshot: ShellSnapshot::default(), workspace_ui: Default::default(), overview_active: false, - clock: current_time(), - desktop_clock, + clock: clock::current_time(), outputs: Vec::new(), }; let wallpaper_task = match wallpaper { @@ -409,34 +346,13 @@ impl cosmic::Application for FereseShell { )) => Some(Message::Event(event, id)), _ => None, }), - self.clock_service - .subscription( - self.outputs.iter().any(|output| !output.hidden), - self.outputs.iter().any(|output| { - output.clock.is_some() && self.config.desktop_widgets.clock.on_output(output.name.as_deref()) - }), - ) - .map(Message::ClockChanged), + clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged), self.media.subscription().map(Message::MediaUpdated), self.media_tick_subscription().map(|_| Message::MediaTick), self.media.art.subscription().map(Message::MediaArt), self.recorder.subscription().map(Message::RecorderEvent), self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed), self.status_service.subscription().map(Message::StatusUpdated), - if self.config.desktop_widgets.clock.enabled - || self - .config - .desktop_widgets - .notes - .iter() - .any(|note| note.enabled && note.interactive) - { - event::listen_with(|event, _, id| { - matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id)) - }) - } else { - Subscription::none() - }, ]) } @@ -555,67 +471,6 @@ impl cosmic::Application for FereseShell { } Task::none() } - Message::BeginNoteEdit(id) => { - let mut tasks = vec![self.finish_note_edit()]; - - if let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) { - self.note_editor = Some(DesktopNoteEditor { - id, - content: cosmic::widget::text_editor::Content::with_text(¬e.text), - revision: 0, - }); - } - tasks.push(Task::none()); - Task::batch(tasks) - } - Message::FinishNoteEdit => self.finish_note_edit(), - Message::NoteAction(id, action) => { - if let Some(editor) = &mut self.note_editor - && editor.id == id - { - let edited = action.is_edit(); - editor.content.perform(action); - - if edited { - editor.revision = editor.revision.wrapping_add(1); - let revision = editor.revision; - return cosmic::task::future(async move { - // iced executor is Tokio; do not spawn blocking sleep threads. - tokio::time::sleep(Duration::from_millis(500)).await; - Message::SaveNote(id, revision) - }); - } - } - Task::none() - } - Message::SaveNote(id, revision) => { - if let Some(editor) = &self.note_editor - && editor.id == id - && editor.revision == revision - { - let value = editor.content.text(); - self.set_note_text(&id, value) - } else { - Task::none() - } - } - Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface), - Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface), - Message::NotesStored(result) => { - self.note_saving = false; - match result { - Ok(()) => { - self.note_inflight.clear(); - self.note_error = None; - self.flush_note_changes() - } - Err(error) => { - self.note_pending.splice(0..0, self.note_inflight.drain(..)); - self.note_error = Some(error); - Task::none() - } - } - } Message::WallpaperLoaded(result) => { match result { Ok(handle) => self.wallpaper = Some(handle), @@ -855,8 +710,7 @@ impl cosmic::Application for FereseShell { } Message::Event(event, id) => self.handle_event(event, id), Message::ClockChanged(labels) => { - self.clock = labels.bar; - self.desktop_clock = labels.desktop; + self.clock = labels; Task::none() } Message::RecorderEvent(event) => { @@ -1024,40 +878,13 @@ impl FereseShell { let edge_changed = self.config.panels[0].edge != config.panels[0].edge; let old = self.config.theme; let old_geometry = self.config.panels[0].geometry; - let old_clock = &self.config.desktop_widgets.clock; - let old_notes = &self.config.desktop_widgets.notes; - let new_notes = &config.desktop_widgets.notes; - let notes_changed = old_notes.len() != new_notes.len() - || old_notes.iter().zip(new_notes).any(|(old, new)| !old.same_surface(new)); - let new_clock = &config.desktop_widgets.clock; - let clock_changed = old_clock.enabled != new_clock.enabled - || old_clock.outputs != new_clock.outputs - || old_clock.anchor != new_clock.anchor - || old_clock.width != new_clock.width - || old_clock.height != new_clock.height - || old_clock.margin_x != new_clock.margin_x - || old_clock.margin_y != new_clock.margin_y; let theme = config.theme; let geometry = config.panels[0].geometry; let geometry_changed = edge_changed || old_geometry != geometry; self.notifications.configure(config.notifications.clone()); - self.clock_service.configure(&config.desktop_widgets.clock); self.config = config; let workspace_task = self.update_workspace_ui(); - self.desktop_clock = self - .config - .desktop_widgets - .clock - .labels(&Zoned::now()) - .unwrap_or_default(); - let mut tasks = vec![ - workspace_task, - if clock_changed { - self.rebuild_clocks(true) - } else { - Task::none() - }, - ]; + let mut tasks = vec![workspace_task]; if composition_changed || geometry_changed { tasks.push(self.destroy_menu()); @@ -1082,25 +909,6 @@ impl FereseShell { tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); } - if clock_changed && self.note_drag.as_ref().is_some_and(|drag| drag.id.is_none()) { - tasks.push(self.finish_note_drag(false)); - } - - if notes_changed { - tasks.push(self.finish_note_drag(false)); - if self.note_editor.as_ref().is_some_and(|editor| { - !self - .config - .desktop_widgets - .notes - .iter() - .any(|note| note.id == editor.id && note.interactive) - }) { - self.note_editor = None; - } - tasks.push(self.rebuild_notes(true)); - } - for entry in self.outputs.iter().filter(|_| geometry_changed) { let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT)); @@ -1190,14 +998,6 @@ mod tests { guide_load: Default::default(), guide_attempts: 0, pending_power: None, - note_editor: None, - note_drag: None, - note_pointer: Default::default(), - note_pending: Vec::new(), - note_inflight: Vec::new(), - note_saving: false, - note_error: None, - clock_service: clock::Service::new(&config.desktop_widgets.clock), config, wallpaper: None, control: None, @@ -1205,14 +1005,11 @@ mod tests { workspace_ui: Default::default(), overview_active: false, clock: String::new(), - desktop_clock: Default::default(), outputs: vec![OutputSurfaces { output, name: Some("test".into()), bar: window::Id::unique(), wallpaper: None, - clock: None, - notes: Vec::new(), size: Some((1200, 800)), effects: None, bar_regions: vec![[0., 0., 600., 36., 8.]], @@ -1379,17 +1176,13 @@ mod tests { } #[test] - fn late_open_checks_preserve_owned_desktop_widgets_and_tooltips() { + fn late_open_checks_preserve_owned_wallpaper_and_tooltips() { let mut shell = shell_with_measured_panel(); let wallpaper = window::Id::unique(); - let clock = window::Id::unique(); - let note = window::Id::unique(); let tooltip = window::Id::unique(); shell.outputs[0].wallpaper = Some(wallpaper); - shell.outputs[0].clock = Some(clock); - shell.outputs[0].notes.push(("note".into(), note)); shell.workspace_ui.tooltip = Some(tooltip); - for id in [wallpaper, clock, note, tooltip] { + for id in [wallpaper, tooltip] { assert!(shell.owns_surface(id)); assert_eq!( shell @@ -1421,74 +1214,6 @@ mod tests { assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); } - #[test] - fn widget_drag_stops_at_edges_without_tunnelling_and_slides() { - use cosmic::iced::{Point, Rectangle}; - let obstacles = [Rectangle { - x: 100., - y: 0., - width: 40., - height: 100., - }]; - assert_eq!( - super::avoid_widget_overlap(Point::new(0., 0.), Point::new(300., 0.), (20, 20), &obstacles), - Point::new(80., 0.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(80., 0.), Point::new(120., 50.), (20, 20), &obstacles), - Point::new(80., 50.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(160., 0.), Point::new(0., 0.), (20, 20), &obstacles), - Point::new(140., 0.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(100., 120.), Point::new(100., 0.), (20, 20), &obstacles), - Point::new(100., 100.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(0., 100.), Point::new(200., 100.), (20, 20), &obstacles), - Point::new(200., 100.) - ); - } - - #[test] - fn note_drag_origin_and_bounds_use_logical_output_coordinates() { - use cosmic::iced::Point; - let mut note = ferese_config::desktop::StickyNote::default(); - assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(1552., 80.)); - note.anchor = ferese_config::desktop::Anchor::Center; - assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(800., 420.)); - assert_eq!( - super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)), - Point::new(0., 840.) - ); - assert_eq!( - super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)), - Point::ORIGIN - ); - } - - #[test] - fn desktop_clock_anchor_margins_match_only_anchored_edges() { - use ferese_config::desktop::{Anchor as Position, Clock}; - let clock = Clock { - anchor: Position::BottomRight, - margin_x: 30, - margin_y: 40, - ..Clock::default() - }; - let (anchor, margin) = super::clock_placement(&clock); - assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT); - assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 30, 40, 0)); - let (anchor, margin) = super::clock_placement(&Clock { - anchor: Position::Center, - ..clock - }); - assert!(anchor.is_empty()); - assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 0, 0, 0)); - } - #[test] fn shell_font_respects_configured_family() { assert_eq!( diff --git a/shell/ferese-shell/src/note_store.rs b/shell/ferese-shell/src/note_store.rs deleted file mode 100644 index a5edb4c..0000000 --- a/shell/ferese-shell/src/note_store.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Atomic, conflict-checked note edits; always called from a worker thread. -use std::fs; -use std::io::Write; -use std::path::Path; - -#[derive(Clone, Debug)] -pub enum Edit { - Text(String, String), - Position(String, i32, i32), - ClockPosition(i32, i32), -} - -pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> { - let canonical = path.canonicalize().map_err(|e| e.to_string())?; - let path = canonical.as_path(); - let source = fs::read_to_string(path).map_err(|e| e.to_string())?; - let mut doc = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?; - for edit in edits { - if let Edit::ClockPosition(x, y) = edit { - if doc.get("desktop_widgets.clock").is_none() { - return Err("Clock was removed from the config.".into()); - } - for (field, value) in [ - ("anchor", "top_left".into()), - ("margin_x", i64::from(*x).into()), - ("margin_y", i64::from(*y).into()), - ] { - doc.set(&format!("desktop_widgets.clock.{field}"), value) - .map_err(|e| e.to_string())?; - } - continue; - } - let id = match edit { - Edit::Text(id, _) | Edit::Position(id, _, _) => id, - Edit::ClockPosition(..) => unreachable!(), - }; - let index = doc - .get("desktop_widgets.notes") - .and_then(|v| v.as_array()) - .ok_or("Notes were removed from the config.")? - .iter() - .position(|note| note.get("id").and_then(|v| v.as_str()).unwrap_or("note") == id) - .ok_or("This note no longer exists in the config.")?; - let fields = match edit { - Edit::Text(_, value) => vec![("text", value.clone().into())], - Edit::Position(_, x, y) => vec![ - ("anchor", "top_left".into()), - ("margin_x", i64::from(*x).into()), - ("margin_y", i64::from(*y).into()), - ], - Edit::ClockPosition(..) => unreachable!(), - }; - for (field, value) in fields { - doc.set(&format!("desktop_widgets.notes.{index}.{field}"), value) - .map_err(|e| e.to_string())?; - } - } - let updated = doc.to_string(); - if updated.len() > 60 * 1024 { - return Err("Configuration exceeds 60 KiB.".into()); - } - crate::config::parse_source(&updated).map_err(|e| e.to_string())?; - let parent = path.parent().ok_or("Config has no parent directory.")?; - let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; - file.as_file() - .set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions()) - .map_err(|e| e.to_string())?; - file.write_all(updated.as_bytes()).map_err(|e| e.to_string())?; - file.as_file().sync_all().map_err(|e| e.to_string())?; - if fs::read_to_string(path).map_err(|e| e.to_string())? != source { - return Err("Config changed while saving; your note remains open. Edit again to retry.".into()); - } - file.persist(path).map_err(|e| e.to_string())?; - fs::File::open(parent) - .and_then(|dir| dir.sync_all()) - .map_err(|e| e.to_string())?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn clock_position_saves_without_a_notes_table() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.kdl"); - fs::write( - &path, - "desktop-widgets {\n clock {\n enabled #true\n time-size 80\n }\n}\n", - ) - .unwrap(); - save(&path, &[Edit::ClockPosition(120, 240)]).unwrap(); - let config = crate::config::parse_source(&fs::read_to_string(path).unwrap()).unwrap(); - assert_eq!( - ( - config.desktop_widgets.clock.margin_x, - config.desktop_widgets.clock.margin_y - ), - (120, 240) - ); - assert_eq!(config.desktop_widgets.clock.time_size, 80.); - } - - #[test] - fn note_edits_preserve_unrelated_config_and_multiline_content() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.kdl"); - fs::write( - &path, - r##"// keep this -theme { - colors { - accent "#123456" - } -} -desktop-widgets { - note id="a" text="old" -} -"##, - ) - .unwrap(); - save( - &path, - &[ - Edit::Text("a".into(), "one\ntwo".into()), - Edit::Position("a".into(), 50, 60), - ], - ) - .unwrap(); - let source = fs::read_to_string(&path).unwrap(); - assert!(source.contains("// keep this")); - assert!(source.contains("#123456")); - let notes = crate::config::parse_source(&source).unwrap().desktop_widgets.notes; - assert_eq!(notes[0].text, "one\ntwo"); - assert_eq!((notes[0].margin_x, notes[0].margin_y), (50, 60)); - assert_eq!(notes[0].anchor, ferese_config::desktop::Anchor::TopLeft); - } -} diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 40a960e..0d314d7 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -2,9 +2,9 @@ use super::{ Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event, EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific, - Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position, - config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, - registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface, + Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop, + destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text, + theme, wayland, window, wl_output, wl_registry, wl_surface, }; pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) { @@ -39,25 +39,9 @@ impl FereseShell { menu.chain(destroy_layer_surface(entry.bar)), self.rebuild_system_modal(), ]; - if self.note_drag.as_ref().is_some_and(|drag| { - entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source) - }) { - tasks.push(self.finish_note_drag(false)); - } - if let Some(wallpaper) = entry.wallpaper { tasks.push(destroy_layer_surface(wallpaper)); } - - if let Some(clock) = entry.clock { - tasks.push(destroy_layer_surface(clock)); - } - - for (_, id) in entry.notes { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - return Task::batch(tasks); } @@ -81,7 +65,6 @@ impl FereseShell { entry.size = size; } - let changed = name.is_some() && entry.name != name; if name.is_some() { entry.name = name; } @@ -98,17 +81,7 @@ impl FereseShell { } else { Task::none() }; - return if changed { - Task::batch([ - margin, - popup, - tooltip, - self.rebuild_clocks(true), - self.rebuild_notes(true), - ]) - } else { - Task::batch([margin, popup, tooltip]) - }; + return Task::batch([margin, popup, tooltip]); } let bar_surface_id = window::Id::unique(); @@ -138,8 +111,6 @@ impl FereseShell { bar_regions: Vec::new(), panel_resolution: Default::default(), bar_margin_horizontal: 0, - clock: None, - notes: Vec::new(), size, hidden, }); @@ -196,27 +167,22 @@ impl FereseShell { Task::batch([ Task::batch(tasks), - self.rebuild_clocks(false), - self.rebuild_notes(false), self.rebuild_system_modal(), cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), ]) } pub(super) fn owns_surface(&self, id: window::Id) -> bool { - self.outputs.iter().any(|entry| { - entry.bar == id - || entry.wallpaper == Some(id) - || entry.clock == Some(id) - || entry.notes.iter().any(|(_, surface)| *surface == id) - }) || self.menu.as_ref().is_some_and(|menu| menu.id == id) + self.outputs + .iter() + .any(|entry| entry.bar == id || entry.wallpaper == Some(id)) + || self.menu.as_ref().is_some_and(|menu| menu.id == id) || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) || self .notification_surface .as_ref() .is_some_and(|surface| surface.id == id) || self.workspace_ui.tooltip == Some(id) - || self.note_drag.as_ref().is_some_and(|drag| drag.overlay == id) } pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { @@ -230,59 +196,6 @@ impl FereseShell { modal.focus_visible = false; } - if let Event::Mouse(mouse) = &event { - if !self - .outputs - .iter() - .any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id)) - { - return Task::none(); - } - - match mouse { - cosmic::iced::mouse::Event::CursorMoved { position } => { - self.note_pointer.insert(id, *position); - - if let Some(drag) = &mut self.note_drag - && drag.source == id - { - let delta = *position - drag.start; - let dimensions = if let Some(id) = &drag.id { - self.config - .desktop_widgets - .notes - .iter() - .find(|note| ¬e.id == id) - .map(|note| (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - Some((clock.width, clock.height)) - }; - - if let Some(dimensions) = dimensions { - let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions); - drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles); - // Move a compact, cached buffer in the compositor instead of - // repainting an output-sized buffer for every pointer event. - return set_margin( - drag.overlay, - drag.position.y.round() as i32, - 0, - 0, - drag.position.x.round() as i32, - ); - } - } - } - - cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) - if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) => - { - return self.finish_note_drag(true); - } - _ => {} - } - } if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), .. @@ -337,14 +250,6 @@ impl FereseShell { if self.system_modal.is_some() { return self.close_system_modal(); } - if self.note_drag.is_some() { - return self.finish_note_drag(false); - } - - if self.note_editor.is_some() { - return self.finish_note_edit(); - } - if self.notifications.history_open { return self.close_notification_history(); } diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 2b26452..ab9a50f 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -668,7 +668,7 @@ impl FereseShell { } } -// Restrict Tab traversal to the modal actions, excluding the bar and desktop widgets. +// Restrict Tab traversal to the modal actions, excluding the panel. fn modal_focus_operation(backwards: bool) -> Box> { use cosmic::iced::advanced::widget::{Id, operation}; let target = Id::new("ferese-system-modal-controls");