From d4e2a0013e7a9452add80dddc110e036ebe4ee96 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 22 Oct 2026 10:50:48 +0200 Subject: [PATCH] feat(quickshell): add auto-hide Tela dock with shared favorites --- ...uickshell-dock-tela-shared-favorites-v1.md | 178 ++++++++++++ .../quickshell-dock-tela/closed.png | Bin 0 -> 4320 bytes .../quickshell-dock-tela/headless.log | 1 + docs/validation/quickshell-dock-tela/open.png | Bin 0 -> 37709 bytes .../quickshell-dock-tela/result.json | 225 +++++++++++++++ .../quickshell-dock-tela/session-compat.log | 18 ++ .../session-v2-results.json | 13 + .../quickshell-dock-tela/shell-load.log | 1 + .../quickshell-dock-tela/summary.json | 31 +++ .../quickshell-dock-tela/unit-tests.log | 11 + docs/validation/quickshell-dock-tela/zoom.png | Bin 0 -> 37739 bytes .../.config/quickshell/labfy-sway/Bar.qml | 3 +- .../.config/quickshell/labfy-sway/README.md | 7 + .../applications/ApplicationIcon.qml | 24 ++ .../applications/ApplicationsMenu.qml | 71 +---- .../applications/ApplicationsModel.js | 3 +- .../applications/FavoritesService.qml | 102 +++++++ .../labfy-sway/applications/WindowModel.js | 35 +++ .../labfy-sway/applications/backend.py | 33 ++- .../quickshell/labfy-sway/applications/qmldir | 3 + .../labfy-sway/applications/shared.py | 196 +++++++++++++ .../quickshell/labfy-sway/dock/AutoHide.js | 21 ++ .../quickshell/labfy-sway/dock/Dock.qml | 263 ++++++++++++++++++ .../quickshell/labfy-sway/dock/DockAction.qml | 13 + .../.config/quickshell/labfy-sway/shell.qml | 8 + sway/.config/sway/swayfx | 10 + tests/dock/headless.py | 216 ++++++++++++++ tests/dock/ipc_proxy.py | 54 ++++ tests/dock/session_compat.py | 28 ++ tests/dock/shell_load.py | 23 ++ tests/dock/test_models.js | 46 +++ tests/dock/test_shared.py | 109 ++++++++ 32 files changed, 1683 insertions(+), 63 deletions(-) create mode 100644 docs/quickshell-dock-tela-shared-favorites-v1.md create mode 100644 docs/validation/quickshell-dock-tela/closed.png create mode 100644 docs/validation/quickshell-dock-tela/headless.log create mode 100644 docs/validation/quickshell-dock-tela/open.png create mode 100644 docs/validation/quickshell-dock-tela/result.json create mode 100644 docs/validation/quickshell-dock-tela/session-compat.log create mode 100644 docs/validation/quickshell-dock-tela/session-v2-results.json create mode 100644 docs/validation/quickshell-dock-tela/shell-load.log create mode 100644 docs/validation/quickshell-dock-tela/summary.json create mode 100644 docs/validation/quickshell-dock-tela/unit-tests.log create mode 100644 docs/validation/quickshell-dock-tela/zoom.png create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/ApplicationIcon.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/WindowModel.js create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/qmldir create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/shared.py create mode 100644 quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js create mode 100644 quickshell/.config/quickshell/labfy-sway/dock/Dock.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml create mode 100644 tests/dock/headless.py create mode 100644 tests/dock/ipc_proxy.py create mode 100644 tests/dock/session_compat.py create mode 100644 tests/dock/shell_load.py create mode 100644 tests/dock/test_models.js create mode 100644 tests/dock/test_shared.py diff --git a/docs/quickshell-dock-tela-shared-favorites-v1.md b/docs/quickshell-dock-tela-shared-favorites-v1.md new file mode 100644 index 000000000..7834d387e --- /dev/null +++ b/docs/quickshell-dock-tela-shared-favorites-v1.md @@ -0,0 +1,178 @@ +# Dock QuickShell, Tela et favoris partagés V1 + +Implémentation dans le worktree `/home/fy59/.dotfiles-worktrees/quickshell-dock-tela`, +branche `codex/quickshell-dock-tela`, base `a556cd56`. Aucun commit ni déploiement. + +## Architecture et persistance + +`applications/FavoritesService.qml` est le singleton du processus QuickShell. +Le menu et chaque dock lisent exactement ses `ids`, son catalogue et ses chemins +d’icônes. `ApplicationIcon.qml` est leur composant visuel commun. +Le menu conserve sa navigation clavier, sa recherche, ses catégories et son +chemin de lancement UWSM/GIO. Les handlers clavier passent explicitement avant +le traitement interne des contrôles (`Keys.BeforeItem`) pour respecter le cycle +Tab recherche → catégories → grille → recherche. Le chemin du backend est désormais relatif au module, +ce qui permet aussi de tester un worktree sans modifier la configuration active. + +`~/.local/state/labfy-sway/applications-favorites.json` reste le seul stockage, +avec le même format version 1. Aucun changement de schéma. Les neuf favoris +existants ne sont ni migrés ni réordonnés au démarrage. Une liste vide existante +reste vide ; seul un fichier absent autorise le seed historique du menu. +Un favori absent du catalogue reste enregistré et apparaît dans le dock comme +indisponible, avec possibilité de le désépingler. Une réinstallation le résout +à nouveau sans changer son ordre. + +Le backend Python conserve le verrou de répertoire `flock`, le fichier temporaire +privé, le remplacement atomique et les `fsync`. Les opérations ajoutées sont : + +- `favorites toggle ID` : bascule atomique, y compris pour deux clics rapides + avant réception de la première réponse. +- `favorites move ID AVANT_ID` : déplacement avant une cible encore présente. + Une chaîne vide comme cible signifie la fin de la liste. Source ou cible + disparue : refus sans écriture. Aucun remplacement global d’une liste périmée. +- `serve` : protocole JSON par lignes sur stdin/stdout, lecture initiale, + mutations sérialisées et surveillance Gio du répertoire. Les remplacements + atomiques et les écritures des anciens clients CLI sont observés. + +La réponse publiée relit le stockage. Le QML ne modifie jamais les favoris par +anticipation ; un refus publie une erreur et laisse les deux interfaces sur le +même état durable. Les autres processus restent protégés par le même verrou. +Entrées bornées à 16 Kio, limite existante de 64 favoris conservée. + +## Icônes + +Le réglage unique est `ICON_THEME` dans `applications/shared.py`, actuellement +`Tela-circle-purple-dark`. Une instance **privée** de `Gtk.IconTheme` effectue +la résolution du `GIcon` fourni par Gio pour chaque `.desktop`, avec l’héritage +natif du thème. Les icônes absolues sont traitées comme `GFileIcon` et les URI +échappent correctement espaces et caractères spéciaux. Les icônes installées +par l’application dans hicolor/pixmaps servent de repli ; ensuite vient +`application-x-executable`. Le composant QML dessine un secours local si un +fichier est absent ou illisible, sans dépendre d’une police d’icônes. + +Aucun réglage GTK/Qt global n’est écrit. Le processus utilise PyGObject/Gio déjà +employé par le backend et le typelib GTK 3 déjà présent sur cet hôte ; aucun +paquet n’a été installé. Le résolveur natif GTK évite de réimplémenter les règles +d’héritage, là où `Quickshell.iconPath` ne permet pas de choisir un thème privé. +Yazi, OrcaSlicer et AppFlowy n’ont pas d’icône correspondante dans Tela installé : +leurs icônes hicolor sont volontairement conservées. Aucun nom d’application +n’est transformé en chemin d’icône codé en dur. + +## Dock et focus + +Un `dock/Dock.qml` par sortie, centré et sans zone exclusive. Surface layer-shell +`Top`, namespace `labfy-sway-dock`, `WlrKeyboardFocus.None`. Les menus contextuels +sont dessinés dans cette même surface : ils n’installent aucun grab clavier. +Palette et couleurs viennent du thème Catppuccin existant. Icônes au repos à +46 px, agrandissement jusqu’à 67 px avec influence décroissante sur les voisines. +Les positions au repos servent au calcul pour éviter une boucle taille/position. +Les mouvements et le glissement vertical sont animés. Le contenu dépassement +reste défilable horizontalement avec une barre de défilement. + +Machine à états pure dans `dock/AutoHide.js` : `hidden`, `arming`, `shown`, +`waiting`, `held`. Au démarrage : `hidden`. Bande sensible de 4 px, centrée, +limitée à 360 px au repos ; attente de 180 ms avant apparition, grâce de 450 ms +avant fermeture. **Un seul Timer** porte l’échéance de la machine. Pression, +glisser-déposer et menu contextuel bloquent la fermeture. Le masque d’entrée +laisse passer le pointeur hors du corps, du menu et de la bande. Les changements +de résolution recalculent la largeur disponible. Polkit et le menu d’applications +suppriment temporairement le dock via son propriétaire dans `shell.qml`. + +Le bloc ajouté à `sway/.config/sway/swayfx` limite le flou au namespace du dock +et reprend `$bar_blur`. Aucun effet global supplémentaire. + +## Fenêtres et actions + +Un seul `swaymsg -m -t subscribe` pour les événements window/workspace/output, +possédé par le singleton. Après coupure, relance à 1,5 s et réconciliation de +l’arbre. Les rafales sont regroupées à 60 ms, et un indicateur dirty préserve +un événement survenu pendant une requête. Aucune interrogation périodique en +fonctionnement normal. Les requêtes Python utilisent directement le socket IPC, +un encodage little-endian explicite, une réponse limitée à 16 Mio et un timeout. + +Correspondances par app_id, classe/instance XWayland, StartupWMClass, identifiant +desktop et exécutable. Les alias ambigus ne sont pas attribués ; aucun titre +n’est utilisé pour reconnaître une application. Les titres servent seulement +à afficher le choix entre fenêtres. + +Clic gauche : lancement d’un favori installé sans fenêtre, focus explicite d’une +fenêtre, ou choix si plusieurs fenêtres. Les applications ouvertes non épinglées +suivent un séparateur. Clic droit : fenêtres, ouvrir, épingler/désépingler, fermer +le menu. Glisser un favori puis relâcher sur sa nouvelle position enregistre son +ordre. Clic molette : demande de nouvelle instance pour les terminaux dont la +politique est connue (`NEW_INSTANCE_IDS`), sans promesse pour une application +singleton inconnue. + +`focus` valide le con_id dans l’arbre courant ; un scratchpad caché est montré +avant focus. Cette commande n’est appelée que par les handlers de clic. Ni la +machine à états, ni le watcher, ni les animations ne l’appellent. Les surfaces +layer-shell n’entrent pas dans l’arbre des toplevels et les snapshots Session V2. +Aucun fichier du greeter, PAM, Keyring ou moteur Session V2 n’est modifié. + +## Validation reproductible + +Depuis le worktree, sans arrêter la session graphique active : + +```sh +python -m unittest discover -s tests/applications -v +node tests/applications/test_model.js +python -m unittest discover -s tests/dock -v +node tests/dock/test_models.js +python tests/dock/headless.py +python tests/dock/shell_load.py +python tests/dock/session_compat.py +python tests/session_v2/validate_keyring.py --sway /usr/bin/sway +git diff --check +``` + +Les tests graphiques créent un compositeur SwayFX headless enfant avec HOME, +XDG, socket Wayland et socket Sway privés. Le test Firefox utilise son propre +profil. `wtype` envoie Tab uniquement au compositeur privé. Le proxy de test coupe +les connexions IPC pour exercer la reconnexion réelle. Les enfants du laboratoire +sont arrêtés à la sortie. Aucun environnement ni journal Session V2 utilisateur +n’est réutilisé. + +Les captures `validation/quickshell-dock-tela/{closed,open,zoom}.png` et le JSON +`result.json` proviennent de ce laboratoire. Les scénarios incluent clic droit, +choix/pinning, drag souris réel, agrandissement mesuré, entrée/sortie, ordre durable, +refus de déplacement, zéro favori après redémarrage, installation/désinstallation +d’une entrée de catalogue, client CLI externe, navigation clavier du menu, +Firefox, deux Kitty, flottant, scratchpad, fermeture et résolution. + +La suite de non-régression Session V2/Keyring couvre 93 tests backend et ses +scénarios graphiques. `session_compat.py` exécute aussi les scénarios chooser et +Keyring existants avec le dock présent : prêt, acceptation, annulation, timeout, +absence, verrouillage persistant, collection différente, redémarrage pendant +l’unlock, succès/échec de restauration, nouvelle session et focus différé réel. + +## Limites et intégration après revue + +- Une application sans identité desktop correspondante reste visible sous son + identité fenêtre avec une icône générique, sans épinglage inventé. Les alias + réellement nécessaires peuvent être ajoutés après observation de l’app_id ou + de StartupWMClass ; le titre ne doit pas devenir une heuristique. +- Yazi/Neovim hébergés dans une fenêtre Kitty ordinaire ont l’identité Kitty. + Le dock ne déduit pas leur identité du titre ou du processus enfant. Le + comportement de lancement terminal existant est conservé. +- XWayland est couvert par des arbres de test ; la recette graphique privée + désactive XWayland. Une vérification avec une vraie application X11 reste utile. +- Le greeter/PAM réels ne sont pas exécutés dans la recette. La compatibilité + Keyring et restauration est exercée avec leurs fixtures privées et les vrais + composants de production. Un cycle de connexion matériel reste une recette + manuelle après revue, jamais déclenchée ici. +- Les PNG ne prouvent pas à eux seuls la qualité du flou sur une fenêtre réelle ; + la règle SwayFX est chargée dans le laboratoire. Aucun test tactile ou écran + à facteur d’échelle fractionnaire n’est revendiqué. + +La livraison reste dans le worktree. Examiner les sources, les tests, les PNG et +le patch complet adjacent au worktree. Après validation humaine, appliquer le +patch sur la base compatible dans le dépôt principal, sans copier le fichier de +favoris. Vérifier d’abord avec `git apply --check CHEMIN_DU_PATCH`, puis appliquer +selon votre procédure de revue. Recharger ensuite QuickShell et Sway pour le +nouveau module et la règle de flou. Le rechargement n’a pas été exécuté ici. + +## Références des contrats de plateforme + +- [QuickShell WlrLayershell](https://quickshell.org/docs/v0.3.1/types/Quickshell.Wayland/WlrLayershell/) : modes de focus layer-shell, recoupés avec les types installés de 0.3.2. +- [Spécification des thèmes d’icônes](https://specifications.freedesktop.org/icon-theme/) : résolution et héritage délégués au toolkit. +- [Protocole IPC i3/Sway](https://i3wm.org/docs/ipc.html) : framing et événements, vérifiés avec le SwayFX privé installé. diff --git a/docs/validation/quickshell-dock-tela/closed.png b/docs/validation/quickshell-dock-tela/closed.png new file mode 100644 index 0000000000000000000000000000000000000000..0d5c96b47a041abda9ea41d6554b6101ada18aac GIT binary patch literal 4320 zcmeAS@N?(olHy`uVBq!ia0y~yUs8~~Ck5(z+3h{pg(a literal 0 HcmV?d00001 diff --git a/docs/validation/quickshell-dock-tela/headless.log b/docs/validation/quickshell-dock-tela/headless.log new file mode 100644 index 000000000..43d157f23 --- /dev/null +++ b/docs/validation/quickshell-dock-tela/headless.log @@ -0,0 +1 @@ +Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-88x0lspm diff --git a/docs/validation/quickshell-dock-tela/open.png b/docs/validation/quickshell-dock-tela/open.png new file mode 100644 index 0000000000000000000000000000000000000000..f3be7ad3bb1ac0f1f50f08bc672d0820f6bf8b27 GIT binary patch literal 37709 zcmeEu_dA<^{IyoARFzikqDoO)?cGLek5;J>qiV$71QDuMRqahswQAQED@IlAqBe;g zGeHnUm`DfZ%csJ->;I~`7G-n-@m?*A|$M# z!9zZNUH>Dv229f^KKr@e{-9=<9KuQKBG%XH&@R+I4%fq&TMvFN@R>3}3SMt#c9AR~QwL`W< zx^eB!)ulbSA$IlZ4PEEeVV*46dXEj`Pb+u2JMcMkyq$uM~idNI!0^Yxe!h!6R z;c6WBwf*)xFJt1BwtM+pWxbDUi^x;+4e}FshE*n6V?jYD#hKejNB?~8v&seP?|#@=noVPx1(`3bCF(O_W81$(zsLp~dfrs!v8g@29}S z*1*k~N!3w#=XJw|8ehUPK4>E}PtlB|B5O(ZIfjhlqtz#G<2%G@5Df0*|BYlz+CR;o z+5+V%&d4j7h`>5LkKVe=+K^uE5v_twB~U7-r0M6^9)0LPTvsQR^^uX0A}d9T^V+sA z7*tblmOQMMvGgUJNQTpOzkYglB&Z&vq)gow^8I9kEjW0!ebQ><>dk>CS65B%<3YMa zKu6A)IO+m1^c~dAgug!W;PYQn5then02670^FJZ-djx!}72z`PU8Gv@LH>2JuF*%= zI_W256+P$OEds_Od#BtfmsjJ5IcF{wCKzXIU_8sPVH@y86Hd=8=OphwnF-HTn4!M= zYk8d3>a3z>;NC-x2JeshyRDcUw)vF>_=my8E^wq}W1&djHihiDLez6|gzfsk z(m<-Snp$6)h=sDE;yaOa?RBsG_ugy>Wj$e|YBnUKaJt2jWV>_J@BAeO?9PYDY~?jdmBM;^`1Hidl?*82r<$a%#Ilt(^m)F|m;d)1-8y9G)b>Y!s4tG$$HW zo@hP;FDAev5)`@?wruY;B}haLrD!G%#lZ>N-XYs<5j>h^`1&2Q9I#|~z0I2UbPjtE zQrH$deX$j>F{#wIVCtju7w*x5o@uBDQcLe;bP!$c0~|zps`^BZnziN^heXiuC~jMh z6c|JtyvpV7az;L4Q3iY$YYFZuh84{l3)AEw<&g4_y|;{~wVQV}?~5q;VW)0kNPnjB zz86KrOhv>6Q;?RA%F$jrSSYVzfep*7ipc}`xmd(&R41;{hG6_Q+_?xx4k1ljc9&Af z<9{^mBD+!(3->k_4gX|2_5n;z85RSnj)K!OEM8h)rY3oF2UK@p_>49Kb&pv*NOnl? zPajGR1=0rA5C86O?(hGJj4jtw@gDs3E58ry7UDBcJ~0tRK=O8epnAW=B-LnpmK>+z zxc5dKi)_G+xzcjZAe!ElADhtTPKKn`2!;X(^>Q-V#re)k)vm)G&VG&r$=PhD*q}?T zN_uZ@{fT-=j#TTj_6!GF$GJw11@rXbs#$PF_DMT&f#GjlW^pDV$sUwtk&iVi8jo$KBOu_tDHtYt|QU=!z;2GhC zrb@%~nn^rdg~Mkf#Qf3(9!Go36~?j(9nIYTf$Z&1unPY8Mf~o}fj?GQsM0g?L>*2r zLS<=#H;(6;a_jmFjspjv`Wi{Iz(I)9lgi%78W&uPe7%a~tmzsep^nL~odZbC=h$(- zbpT;wsnir{>n@EQu5-=wX`fL&dEuh8G&|ei&g*PZL|A;Pne@7Pa;IVLitVnxyN#dP zMTg^dg6}FV>l|_Y3XLjWO#5Jd7t3_%eTV-P%vV@EO=2 z+!wv^kuzYeMijbL9zx0#WtW#6BgiESg85wMtsp|ly$k*b7U30#xSxN2QMXOA_m?-1 z?s{>aFEo7%e#@~8)cUi~nh;M+wpOkTm8tU zYd^`Z-BEn86R(U(c{@7S4nd!&H(i|!d5qr`FdI$9RwWHtbq$xJxXt9K_nI=5>Z^^r z?M62$l<+gSx*t8ctzddz?B{GYYiBoC*EFX7MVpu8q-72G z7d$pvdTEbeR~~%Hb9K)l&WET3{xhj=PoY+joo!x$&hvs=z=CBn9bPoca`u}kjh*QartJ{?Syh!$@(>8C5k0fhEs#zhAG-*#{VLW)&+W+PXO7M zwHLco?n;jycl6t0P?+Pz#)(8YhUFc04E+{fMTQ{z0Wb=JqNxU!dUqfyES_UG~`6_@aX+tZeWPqe}N`ucL~ zzbu;!_#Y(kY}MJf1s<2Bx9`5<%WFFWqFUf%4)w#lTg<8_s4DUGps#X1!sQ-Qb9Lb1 zU6V#Hq43wb6WG1Hpp&vO%P_15;q<5p-Eqx%F{yj7QD~oBgGiH-+@Sph)mCnF*}s6} zsz>!L@LBW?I-Tqq8IxLfs!2Tq&x=~U;=ctapKb10AvBY&m`AYcG&V;nck7#GAmoe+OYf1L08+6=PjZ-wK4VaRp9uAd7W0blGUgG z&`OGUyHu6x-5Lp{;!#eu6S$IuL_2u2%Bi7cD?C|U?KcO+2Us(z??`_1=wm2%GEb2( zNCoQQaf-}C!QHW#IHRV8josGoX%>T9m~qP;OXBp!p)ul|Lv$`JsIuV*lRXSX?MZoN z!DnApJr%K6QU({Rw|tX-H_%!==^m>H{KI8C{n{Hn1Haq;p|@5)Xr*8@Xd{4qB6V=R z=6|>4|K0NCdb}xa-I|cBoZgvrcAfD!@}0Ku9u!ttXou({Wfkxi;5*9hupImo&7{!r z&W4f>ueMt7cV)=Wo=~&Zv90a1!N^uOzCKPAg5Ux5<4MmkLDnua5wFM5Ptz;}qW;B^ zH&h*z?9r-kwv@1sp3nhgv27*od*{;(Un1tS=vs<|rz6QTeF~UoG9?vowCzD}PTz ziG-;t3xpd{TmkGtn?L5OKt`l|zeLHSd9aHU(PG=0f?%?;6k%}1rU-k{9eE*XNM)nV zSM*c{p#th>-j1u~ps%ld0?JZ!MDRd(kh`-SVOz~@Xy|TB_AHgWu}*f>N~2csG;yRg zY_=}OERfyCS@g>YyA=D)d84~ zCXO(kHkQtf8+hXOueW-GhySUx+XT7AEw>xrO^u;ek))n$uD$5x)LlC^!oRKY{dXr=8r z207Aq%a<}I={`h-kjL1wWa!9RhlzZyKQ(96f{nT}sl@G%$oxq;7(wCv7(V^}T-pi$ zh|vD)kYYlqAnqw)MCz)tXJ%&3BHXcGQk~;w{TKd+&d=TmJpUI&E&0=A(rFKd%e5?@ z!oUe2c6HdvsaZNEHM^c*Hk>@W1x)AD#DnX3_d>Kb^gv*tA-&myS6!8fK2Ss7OKM|< z4w$q=QX!PbF^d-tF5}+9VeIkk&cjt`R$Keo5l)TS13T$q@1hT8B0Q-E%tB0&x03Qh z3k`Gtl#^z%iphFIg>NU-8q(f5=OZ;*Hzwsa+7>q)of}%A7n%g6+nMVU3dF0af&(Is z$9EqT6cmJosbBcMjsRR$V20yBVrM9MWyy+n9${BEJC4wfA{3|hXPRTF9RW-g5@cP@sx3Hr>XF02B0mc6EvVRwPKpA}+;!H*mK zJOqds@&-0+_2Q*TC0sNBwR#JOF+7o-$5d)_h0kI++?9exL%O`Lyg-sS%)7cL7bue& z&coB-bX?LzI#O4;=xU5=+MBHd70bUBdXO#x3_Kd^gZa%l>W7?5sHv%=f#o~TH|e^^ zpJ-QgoX_T+|3d?r8bj{;pJ=Ae!p6>ELme(48SdlD!^RV8)r}{xMmH|ps{x#`dx%^3 zRP>aDup{ZbeV6{J>1OuXoJ!P!cx0nYdEt)2_g2eFc6Zrf72lvlUk4?l7OT}g(KXDg z^ash^S!*dp5jOVS%k0MrcS|5we!w?u(nen?=n_}HBV9xu77+%Cx#ee!Aslq($S~1j zwwXa2E+^0q^omnlqgf6djhWY1Jo{Rg8;GQJYp)?SN_-Tu`gBuhs zqyrMHskYq7$=R_C$d^*^oNlo}GxS`Xn>&BP)c97I4I96RcTef()FO#4TKrniv%Fo+~O^HD=1IZ}A!WGJd%14K zDHtiKNqNFj7%gJk2!W~zEuMay6Vr)NX$TZ8bUe??Jv>hwxafmA$Ig}b1=ssFCWMoM z&s)Yg@?KRGZ6QcoC%+exmyOVO+DPCCSOj(=E^0raO-8`q;tde}tyavh|bbx-Gz$7e%7P9OW=r zJ6|TqVrR^B2x|U|rg0?FrPiKrvnJI2QWL5C0?(%R$1+LAbEo0&PKRyvOpUHbBABGX zWH^7Msi7xm$IuQ9Psc5bC+o01#PyPpJ_hNYLkHj=A z0LcJscz|6w`u%X_6Xi+#gAS}bx_(Lx|x^dhPTu#q+{DLF3oM%O*yr{4szfm7*S^xz7F=gO4KoSo*d1}Z-pZv7@YJY>+&omx}=7SO9_?ALawgJXI#}W&mc11hQ z%Z`gf?bY)8)QT>IJr_OaZJ5~ymp^ZV6Xj(rq|jb!TQ`~RUSLd;Nb6`vhl`1$<0fOo zj3VLmIIoHWGPjEw;PMHYsbakip^bxHa@$xTxN;WXP1c1=kZx}IN5L$#t}H`IZtzsQ z3SzZJo%8e+R-W$GRYXF4<-wHqj!KXrC?)!+->T?);QUqDPC9^JuL#(vcGXz&T{ZK( z&(L|nJ0Ug}mOETJ4|C_>5Ym)7$9x0!{e~91v&yhh{l)<-U!53v;yc`1!OQC{Gw&Bx zIay6Nu2p2hU+?!kBC!2Ub(C6x@xe%Z?j!sQZ_m*ZSysb#HgY=vOQp%yi0NvO3l~GS%^u z#bxmF4KDZNddRCbf~h!UIhxOHU;03|i zB<0hkFn1`9tuC8E{-JVPhE-nHq=V!oCNiG1*2sxKD`73*PDU9qyHgihy(=5TeWW!$ z@KC#6tBiqVCzfwE8_p!~_w%H(sQDi$b9HgJ)o1;D?p{k(^ey z);*9FVNS+xZ*D^21rRIecrtODy?PE~qd7+l8{t60d|-PSzDYc(p;`go4EPz73lpZ; zjsq*@l_t*Hd{FTMwte&ftg+9uU+AoUdc9lg{YXp2BV)EN`h)F6cs9Se+&;2z2MCtN z+~MMJ+-Q!#V6{Y}e$PF*Wl1cK)(-LRjY66PujTHN$xB-_aPG1{BXMO|e7FJ?XYl^z zTRrIERcv;lEcTQ6DmbK8I6e|y_p;kj0VeX{UIH!iQVO6;(UVDnORmeAGi#*!XlSOz=I#@VzK?&(tlOe>SlN;!=zFjwfb{7z4 zyBo1l=9U#MnMwHY56?6@Gx{;tlH zJT0diFmjRbH>+u`;^=StJ0&r({eJympWZL)!`g&hLS@NT0sXv8T-7MinbNvN+EnBA z`pR>Rm2<<~C6NFGS?zx^-d${U?^23_l2XDGIxw&%hUAObUMmzKf_);sVWf(apQKV! z(J#H9AGpfUPhn$OA5w|1iGrvzX;)+r29D1CdG>4GowS8kA;EXa zvQ(UoSJKUKS<{!6;|CEYTvVF=xNIbGvO2hq;5g+x?Y1dUgv@@QBv{qFo8_Df-Y(@! z*CHxE%_`n%bd3+jWy?UTmgtqA$@&8E2 z-pL_!0A|ECm{u7tRhM4%r=8y0TVAqZmmAA+iD9ZG`ECX#Z#Cuokn5u2%XwF+hdQg{ z6tRarU%Mgp^W#{}IyyJxV6O>E`kSx12T41OV{!$G4_mQ6Wc|^$vaNl&N-e75G!2av~SKPoa{eyDMaVjLhY$!gug%3Hp*rsELK zqnXS@6#+2d4+l8ddsN=T%uk*YICX=GBmH}X@`fr3OhYdhzHVYR8nquet%!$@I^|Xw zUXA=3>8R7E{bafIGEgjFZJn>fv7+r6IEy^Fm2@~?4JAf!a*za{!G7zTQfK!LcTvGh9qPq6j2V7wq;K=dv?2*?g)37=c}gh<@7sjn zYN{`A=>%F|2Zzl{cj&x!6}<6vhQ+&4_@ODV$;}mMHt{6gGLSfrG8IX!BLo9mKcUXz zRESX_rzH`Ln!z&m$<@b<|JuTgEd$O%E=Q~7&o?i~!4SD1|3hah!UhCI2vsW>BL)!Y z0M!}0Web0mJUM#`dIYDdI5^l%*FdWik`HIDrdQmegoH#uh}9ZSQ33jg!@=8SYr&a< zWS@A|{>aelBYjx8pd0sj??A8$78Ondm^}KZN|}!|IgCcI61t-xh*wPwo*2KOv9U3U z5qZL`CZzv+Qtqob#?NVaFRn9s9F~IE6@P|IuPS&=7bU?tlQMxeJ0>`1-HD60&4(=d zt;_AzkP(TduXkyM6|wbW=7OB;94#J$TcRx&pVa*@?f7Mwg_F`0Q*zZc+rFt5kINSF z^)<)LRTIJ9pPrhsHUT!P8mn!SR|LgYu>RU1&xnhvVEt#Xtdfl>adY|ine)VITDTSg zyAXsOJ9;g(#cVsBkz7T8hA%g~(^o0dzP8(yo?|TMW|vPl&O?)*7%5rUz{8_i>m#(- zEUgr5G1DwxVU<{Al~u*={PnIRYkqL^Tt-DntYjgVrm6!+eGE>-lfZ*O=+0zUWXn4c$|gT(!%W3hzQpEsYK8EeU9Y@_&yV6b{~M*8QS6xi+5tmo zUmMp(`4Q&@QwJ7wHnbEnp@imx-&iCR-|mg&S&R=31~ee*=ZhaQGoPOJsrU>_iJH{f zR`hvomYR#?1szg}299|*xkA?RqZ)^4q|pp>HX78*o9yn&ns9Torh`qXo#}TJ6lM}m zg|SWz@)kLnnWZB`L;su_%-WIWWtCBm9u5w(WemLro`?I(yD{~Bos30t>*KbIzCC$D z9Q~m6@j^e`5GtH5DbV$>G+idG>NZz+C|#g(fGQ=|gQP8qQITDh*KlkJJ9#Fdsll;e zTD_p>%4U`tn$>(b9$}9c3hsOkyCL>hzv!9&919TBipOjZ>3~nd&r-|cID3XPTbH{A z2$rOQrnc<}o1PfL1v)e@vbs&drA;gah#%$>LtXV%{F_c;0FjW5T$ zXX1ueG|9-=$mn zA^%e68JH<0h0_7Jb=kOdRewYz@L2C19;MLqi`sVJ=f%W@#s z&~9E;{B%|qEIi>#P|S`}#T(x4s}`nBiJ3o{?kky4E5OO<0ks9|RHcBunNI`W;pG0q&cURz@@lu7#X8U^w*3SQ z-V1Cyj`LjZWkT#&kc!MjqJ~6JLmW#y;U23^ZXLLhJQ@7@k}EjN<$;?W`{8@<^H}GjOeD-eb3TvDNQk zZU!Y6VKo`I!|83MHhvP&R%KM-bA0$DkI>24zFAAnsIUq}QPBqT^TbDSUbyyI1>9Z&~Y37?~9G)~3 zRu(AL^=1bI4h0(zi}zK8Y)FhcTN;R@3#P&vme{~U7mHK0y!m@%BUsGokgaQ|&&66i z2`h>^s8P$R)Aw?4xLo>#8`-(s$gQ^l`Svwt@6OU2=Z=RkUy;Ph-*v&fy!N$Gj+r`W z*-14-0l^i}pY$Z%BCsD<6+BXABv|LoqOx$h(p18T2&?LO+6SU7-bC7u5#=JzCL%WU zW?YW!@noYGxjgwuNb-_n3(!K7wsW)&M-99P5#ABn1|s|V8fAAIRuX_)R9waxB@(HT7*on2%rLoNl6LqL&atYHn$%x zx+y|y7^e_0@h2$}-kW4EgKOycEJ#phpUb4;(mdRIE8iKhGy>h}acP#JV`Ga4#~?fGJF&ZzP41$n0uBuJEqkresFw!0*x#nFKlTvMwAyl? zzxl7@MZOKl0@TmLCB%8x81^OrnH)1132=}c4`gM{XX$j5(v9i$A(q*0IV6P_j%kdm zas5-B%OHjnY+&f*lI*V4@<+0e8^fe)gJd-~l=l|e z!R77#3P7dsqkz%Y@4m|TnCeT+<+c^lXkO0O6Q9@7v|86bbqRC}-sB{mx4Q4TEhQ$N z9R}cAQIaydaf?1VGz%??5aUSgnDffw$y8rZY|gYiP`_sf8*qUIKO8__ItQawf>#Bt z&L>Fc81ThdHsY{%baB51A&`#7M}@53k|5y(_4lb=WL)`2+3Wrul6YaFXoc#K`FZ5|R8!klEvos{x0*m?pGT_{dW`ilS%nZ41X8t< zqLD7dt9@U5&!7jeyBJeeqkP{FVmhIlO zabFkWLNnB{4H9+M{TWEin%ps#N$=EYXt{!zY82mj2>R{2c1smm`2Nv&Qb|6_2N;mVJom%9S!kC;vr3u zb0;xxD?=vg&+Iw;uz^8)Z%9J{$3bPel-qK>EuY@I}DV>Me{IQEi zaC4o>$Uw`=3tU^kQihx0P_GAr-CZMjAKFmf?~Ku3Q42?<8X8aM?ZXr(1}3=`nTn2V z4Y+R5(0GMTSkQ+wjp4c^btUriU+`AFteEtsX-liJ2e7eu<*F z-`ZpIVLn}T41i~8NYd0jAK7R z4!!6KT?n1qGXq#{|Li~7_ImYZm*(o;=>X;4SdR!L>E)ilk3-~GO-^cq57mOsZAT0)Lo-Ciq- zxQ$9Dnl*2J_f|QGrj@JoaxuR28!GHvw&-3&2$&*g#{bQXySOsl?zI)zN}9d-YNzc{ z>tS+fT}YB5scDr7nXO2|9A%?H3~l7<%i$C3i5KXf_U=_TaAgtJYI@h$AZ$ort(b`p`R2K0nW}_+P7G9dY zGGg@%ED{}(*Eu;MeS(6zPNVItS^LM!=wf;e8ze((t$PA!RW4romN|j2zF=mMXr{WypZ!bspZ!{URN9yjb8E)=c{Fg9AuZ_Td)6|N6SPv zPjq#efs6cmlsL25pfFzN6I%xDhP2UNi@;&rfF1MbuIRsvyi`HI_2auQ}XGek*%#W436LG z%=&8`8&hM2I0H>vhXY*6Uwe{zg$iH`<-25e zRP~=E$HrEwhAr}$MUY*)ezE%K_5THFjsN5HVfjd(A_UPZ_OQ(L?$Q_Ea#Y}0W)(dx zwr39oh5@C@q-CyuToRck>>m#QI4p+jMAnH|p>@ZdX?EG=%MjDwj}Z@({Wh{WwMQT# zh)2S_8~e-NLpiP+o8j-y6xFt~t1o}ImzWdt&p;M_=dCKJ`AU}DZC1kX0<{8WcQx&Fwj!W&LcT^AxF%;ktxf}g01L#LFPts zXzR=h;G?Qc-KUhyowCe)DKC~ki%V{1MDdy>^fI!-fuqp&QXu61Lk;2_|0W)v~)Vf-GymHyxAp5BxrJj6nbzv$B= zo!`NtW}{D`Uf=LC>L>G&vrnr9&P7Yt`)_+;arw|wP#!|mP=(m*b>+Jq+kPDnpc z&yo4UCTN#MtRO*FZqdup>v#5qFx!`d-cRChWG-HR*kyrYQn%`YJG+I~v-fS~?J_9H z1pa1v%U^5+n){5M<3qmNIn6Q%t4^Kr;}!F1~vylWr1 z$_sI$IrfF9SQW1vu@24}MY3?JJ2HuX_IkBdu6-;NR2nx{@q(HpK8&1w(-GvwP32z# zvtVa8g|RCiS8 zTJ(UsQ$UHj%#>t%n!ob1ZHp)VvW~&qQKhBcFv#-Y3^cGKjOb;csd+PgZ6`B^h1IJl zGW+G}%>>J5A@6$vEiFZMW9CWHg^bslZ&Y z4^%k=5n(bdf8YwI-H3zA6ZhO%-lzA;CL|?hzp`VhhFc_SE^*G?^Gt(~hRC%*vIx-DfR3G~+kC3aa~D+PL^*G&iR_8l((G-%YNNemMVOI=x6L+5F8m{k3Zj&~(xo!8p%y690CKcT5PRG2!J zY?F(9QxXy4ZIJY7+&-EW8?|E zNR{1!%+U1*XbBYYp$YozV`8Xf7-ee&TteJ44(`mzmdHOYIaR=?7W-4fG%Be`xhr%; z+@~}^^}eZ+w)w*KZaEMPNL6k)LV>&Z%g>^FulU}8#^k| z3Zqx1LfzwjaLs0_5t?Bkz0Kk-THABr|62>76UIUzL*vE@V}WJEB<|K7sgtEiQkVD* zSZAi`9@514IwH~@Ow5`ZbVDr+pq@e@O4aAaTksAA8tFYsz%5wtbjrBXn-Ouj?i{N2 ziv<=vvU^;9FIKZ*Pa*)ziG8_0lau<@E}pz{*-gF27uPj}P5&a6%`vhsY<8J$`N{ohj3K@ATeJtVaX=e> zL*+h_F0s|oD=3wtCe|xQ0%UmQM@Va3qI`4WCr6z!9$7pdC7!A`@B0r_mDi4e4)zzf z`17=!X1&tZ%m4KJ_^V=vE=!QW0{-W-(~67*@?x*DHP#VWMnGi+&9A=Pp^D2S7UAB! z?BYz(G}t~6G|cmF>t@fO{*=wLCWxMdDKpoE*Sn1+`O)_8sDpxq*_*j1F^fMIikDAD z6QhNSX$lGkeainaSygk>=ilY$=6mDmXj0b<&`8y)z@4jgW7XN94-E-66REV!LIyec98=023 zcjvMRcnyw2DItEf=`T&8Rs=a;{rHtYI-8?G1nz52)EC{6wQ(Ng81tCXk^NinyON&& zS~TvlH54FGG~Si~e{fSElA(9V&S1=c(SJ15v;@ZeSFGqh*Y%GFxvEo@eQ}&*&mAeh zU8k9kI?VLpf7rk2fEIjpPlu`V_I;(?>#M)U9F%(;j19$JbLN)}^S+5r*8`Dp zfBX}Wf@iCcd9}mhcuy17ZQCX@su?d^9-ybdHT&Yjnty(D6!7``{?;Ke+^SPqQzwhg zZ<^#CigX<{8UPjFFDKsX9v=7ad771+E^u*`KWk3w_G%Ppx+%W%2%RBUSD%1AlDO|! zLA9pyzOL@?`?-;t5`9N_#Zokiw{n$uZPXz#Ja?Gy!qP4?s&`-+0Jj?AT8vcxiXhn)9KqX!V`b*3Jx zL*EH|{`{mT$tuJDR?dh_c|hHt96@IhqCyI<(rlZqn|C-y-)=QS9_%=&ST?ID!wg!y z)_r>3F+>ON5UF|PLiNsnsdBAvmsdBVb5-Uy&iV98b!k3dXJU~-3`+;)nyGXkYn{p)EJIMbh2ws*0ke`&22n6g{rD_FN8xl z82Wd?qT`cN|E5Z!Bn~yQhuAUlUwwMfm1+=DXJm(3qqLI+W^cBLeBs;ZX1~H}h~$kM zvd=$~NpWc<^;dM!fgRLEUNq}`R*dq%WHCr&?+8xM9X&XtPhBJjY8%tU#{TZh3vuW& z8AxKczxoFfKNonh@i!x5@BB z^WN@PH9wQe5aH=E!r4+vc0J(Ps{yoRLa%W6r%>Rv&hTt9$`rA9DmAT@cE{j0eo9`3 zYjWXJH+Zz3Kl&|ugVNe#q>Bm!laMO7>!NQL_2r&`*xlcq%rZ1$1G)oGZ{B+Pk#2!GY>RX5y*4Hf9Dk&In>Vm3iMe^4UOJcmb;IipQsH1_;a3@joqeU% zS$C=8V6824YUjxIgIYaV_=<^jy>Q?Me8bOYjealLSYK;%(9F7=d?`m=md1x1!F)k) zwBjEsokWO+C?$K!;`m;+2#i=}{50*+zf1xCeOhHIfBZUEJa=HOus657%XIx@Ap|R z``m|LQ^LaC24ub2Qr_$BkOZ{%Q;jWyMwRfurYh-C_sBmH{sEcaE4Vv(ns!%RGlvzc z9^5bS07L{;D_#J8h{?0=35*0&Gkqtc6jY-{7Ef2c{@tjNNrc0(wq7>1djpY|1ltCL z7`8HzSY$MTiwII=y!Cfx?r(^Y)yT()TBVN2dt}1^rm%kN`~Dz#<~!tNU#REqQ)dv6^wnU|AzU*zY@KKcvaa*&z3?J|2-ZPK8Mh1ehokg$I z4BJ3~yq`y&J}rfP4b5Dzg^7>oh$Va1tvTq*x!+B`8TmW2iB@owu zZ9d#?3R#&TEq_3e<mBv3~4M!$}kBQCSZxW%`IU(9&61JIC{dUl+pTQQ)bblWiv5uv9LC?xpVD zLT7go|2d$>frVb1XTb6=Qf4f7vtC_oGlxC7woQu^@$w&8#DS(>{K zV5VHRny+BhK`e%jUiM2Kdn;cI;}_xU?O-=KLXV8+gyPSGu5@>VBf;uYeOTuK+~@g@ z&O!5*rRU3U^-ozXrsf2m9wu!7 zM`ks6gf4ijE;DfE$xq-q961l@b$yzl!>VcIYEPtAfu`q*B=e4 zk$GeU+yRLv^uFfcmiKxH^K6{uu{eYzi$VtIf41 zJGVG9x-9(xrqz%scjhJViYhM??=?Ta@=WiwW*$dMFb?h{t9@pm_)l*`T(`Bf%;_o8i*vJWo_wVj{A9pu8{yt`cdx&`9tybc zXhI=-&(Q72ZrqIbv2(XhDCPa;NX1Ss(V5qHzALF0Sz-{;a!3-2rZNdN^j>==|Or0~*en zoS1kECD$hS8buv!3;|qxgIssWX0Q=BUk`;>-XCAuJV8?`oHH3p+SK)~{kC1rj2r;FLq()(WaLQP#V_>T zN@^N$@)S)j8{S20ei9uC(M4IeBZ7Hf_U?*vq1@Hv|IK{Yu%G@_QQi|3>|A-L!YPY^ zg7dauZ%~(<&ALBRTTBa9nnU^RWy$vW32U`dL9l3F-)jt;T~wA+mQzokO=d>mAQ~li zSp&SiBZ`CK90W{BDpp~m;DW zrtVDFAPb6UuF1B3Gzl##^d+}zhBoB1hx2kj zsskO*^hLNV%cJm-A3$apR(AWklm+#op?dQ@2w4tg@^3myZn92FSy^w5xf`FJ%SLrS zO`Bbkcim!7bm}rK9Yrfk8eL_}shV}}GNfX=9vym1~xcU*@-d66soH+t& zcN+SCX!^$RI@-4D9ji^!B#mv`wr$(C)!1ol+l_78ww*L~^6lQw`|Y2zkC|g;t~Dpu zxvo<^!DSb(4B(KLTV1sCQqdaZxw-E2@Buj>@LWWdJUtksu|$uDw;W;dc^4*-I*Q$u zpUzqzA@)|fEVRRy|7+{I{_FEfy~-jJEEuw0D`4X^Tk%(9v;{x7dxqK2s&F&>?7%hnH#IhUe*h)Xwv6-O<$g{~-6&(plx!?a+#ry%W)ossz(5Or#fu};r4r_Zoh_OT zrvZ-&*0S(L^slz!N8x8jl8q)CuTQ{DhL`KOa=rXa+RHF%XlVKaQ?=~=VRGpgUm0J$ zG0NdX)VS$nY(E770H3gapY66n_~Lx%`kF~@_2wDaGLqSTrk9EJs#c9OU00wv)O?n| z0!EAArK0@*YU1=HVcWkG*#{OS<5U<6%6eV+r;{NoNdGWIxri(zo#yl~XdRcA$_xw* z9r1LZ-fdiRysuTqPH5aS_mTFYG|i~U<2yu=^rDXiUN;@Ru7TxRZu6-VDGT6}9(#p^ zXc{v7nropJGr{mTMfW*%?xlAu_Y@SOoOzN;*6?o-i1D7gkDV~~o8LBDwr zqKlG7DG6%)YXnI_m=fk68c9&n@X!nsk%X@FTlWRCCm9G0Nam@|A*S%5gXDuCkg5vp zNU{wYG9;2crS!IY_6FhP9r<_-8bXdIW9ay@S5K*>QN`d$Vc_OH@zYgr+RP>j;5ot# zA36dPD0k9~5Ech9NeA*WPb1cN6yzy{6QRG092UzGF%>`Jk>kjdryWl{QY*IQje47- zXoeGgJPhu2xZc>HJr~-Z;m*W9s-WnXB+%y&x>eUNEn4CCwu<<>bsvX>;=P^^2$Lk~ zcjFPlE%jLrB6+~_FW*mF zZizNg;yi+;3`9GvTm;~^yIC}Pp0TSd$$Vd$$>KfCheGZB zbbNx)yVd7lTdm7y>l`?Wpx{9FYx%zfJRy)h)Wg<~3BjopJ6aT*=s_BFia;i0rA#0uiO!7C%MO0(haKthBvB?Q{3iQ+zRS`mb6zoL8O#FJ;BQibBOcpiddgW`h z24Bl>$UM;`mX%)oJnrI4cXi?<5{dnWg*4jTZ3J^aNrj>22ZAI}=_p-0w4#^LvfovV z?d6061|SIecbXz;v1QAD9vRjAF|=~o-1I>OVf{FnakgKIIuC&riwvO>&{QR3JnUoc z`%rEagu^g`znNJr@_7r9n&C?1_9tF5Wl_mu#VAF|mkbKYS2POUSR-kk$WweN+Bw<* zYhE#nPxGAu{qak8VFVXXh~w@t_Ilj45w3*xrnuS1+-OaKB_}v56TZThW(wG?`%t~p zKQGNE$9ta%wR>5bT)Li%Y|Es=l{lT4DA5gjykEBbJvF5G~VZaDvH_t=@HA-QndB@j=Tr5)9vQ7`8D2$RLi z)wFIg_~UyaI>BQaMM#RWej)(KKB+aj77{q*DSr0Da8{$i!S}-N=9!big8-)500*!M z0Gmer(uQ-G%@-BVBU@f7nvh}(4fc6N==x8}K*yh=3i2&2jZhe6AZr&wsL#Ks3D_ zXY;)AoOvdBgLfypQIv+*Mkr3wYZC^8lSm3FwC3s9W>GaykT_0~ct0#aFOMH&`cucg z=+!IapJS~!A(Q-w-gn}Y+I}f|eLRZ5Al>)#COi}~qMVIibRIm#x9J8=LhFtF&=h`{ zlcHO%GZ|-+<4e+Dulb6xy%H&qizpFIym_+pk%;`7T^Bae71Z`f0(v({X}AFn9iU{vz(uRJIDhWuTkps+ zRS;@Bk0rZ=DR*c%F{UZkvd^UUvt~;Dg><7UM6RDx61vCiJkIT4U9HRo!tyGvZko!d zh8+2Ex?=RtJlCd=$)5XoW@Y;=Xo!jH&DjJ=#XJ+{1DH=oRn(G;n0)bJU?-@!fj^!fH6JH>?XY?J6= z!r$AY`6~oFg`H(}3M6MS^zB;~T1w)ECP>PxN(_3?0x9mT~{HmMZIeUqVxHA4&>l#^+udAvj z21S<0%S+827zn|TJm+rpfLFm5=upbDch2aP*(0*I~ddx6+I@9bNm(ZkSUioZas`jy=g#LPIo$ln1?jq4aeN!1qV9NF+ zV5E0pzaL8?dofRB+#Ox6 zH$7m*HWF@*&l_oRpSH-i!`ol3GN=8=} zZO!fd;r?tZW9Fw*tT&fba2aZ~Jm<9V);gHEq-Kmn*N_i~LjsI3BZj9I+0|@jPcUcJ z`!-?V!dutt3I2_{-@aW-@C|e6if@W&Hk#WI9}#-#m2K-I)yyz}!DF ztH$tHZ!;FAI9;?tL+yF-+nVL|VB$QMv}_Rtrk(iwGXHaYN@*y+Og^|Yxq=z}6i7WZ6IgK|GNfeyx~9P7aqb!8Pm%Cn@lj(;m^FKs zO^u~zNFCjNe@EhsfT1r4O5~vU0XTU+@^M4=`LtM9Q9A9{9R*Ha*0lM06OIX=({oJf zCdsElOdQ4?AzbnpO?}dmhqA{$M`&x=F+!)}YR*3L(mA*WHi1Rp^|VI&2hkClU)<-_ zFmgUW`*HWHQQg??_KUE$5OayVe|Qe4wM}ZKpVL=N);OxYKf37M5>9l@RqlM)jje?R zkIQ5_Wr-OZMiXqO0o+t8E0A0oZ{YQW`*+JuQ5@7`*@MX&!C@*iB*eD+<54iLPMXcA zvZUnrsr!q^zep-Y*J+X^PrkIqZsvu{YrM){mD|J`*7Bq{2`W;)4+9Fn=m{-DEx69B z_q(hxDck6N&5wHgSUaK#c`Q}4WFO9r53@dJChmk(4%(o`=`ojAz`)e@GO$k6= zCBJ$vN`Wxd59P9Vn+4|1i=@k{%A7YaKR44QzrtfRwkby*-{}(yw;|7NUB8(#cKdG0 zF1omRzHQz(L_(O0$ZK@pWr+QVlf#PTDmkBz?6g(E!KQL%FWh>nX1EbMU`AOSsE@M) zv{%xQf|=euEGgII_lZ05)Bfj_-|yrv>ofA z^=(7%Ci***GBgWFLb=R~1aXyQoHU7w*MTF9P3yK=wT6RL&0Cm}FK?Byc&@Ufin^AK z6GG6~1U9G-;$_x^!z~sxUvSfSwvP1%@Tl2D3vP&l(z$>_0cS)=Dizd($?ViN8GVWW zs3@6CQ{SeXQ#Dd4QX%L~Z+}xZpfg(=&x_L9q)yPM+1fAm*k>29%rmxZkWhLQmS@sR zxp>@;9*;kmOoeG*DWxt`ujb#gUbU;)3Z*6_nVO}J~ zTfgyiY&kKU`#E;x**+ZZYEXT$U7-2LVOMU%BGp-+bqOz%ii-`-8=Uigi202;#SH6? z*={D#m4*9nHdA2|)#jwcrU*Px`*-e+`*Qq!$g25#@3Dm!_IhpieLFsyTrckT1?d1b zy+j1;tG}em3-!mz-=a%d8#yQAUG7h2_wA@>)1jhjhw=R0$@pHv6FxluxLt2$*mbqG zx4!}Z{~I^P183Dbl4sIwk&NP%m)R+YzL6iw1)#J<2Hd}z7{3VNVxX$oy%0T zBIbz}&%6{Hz6}~aqBA%y(QtsI=CNgc!%titw+IqKyd`1LjhDO@VNj#i8sSmp@F>#> z{*rcEEa!!I;dfo46ID~yfx6Q4Iuh;lxqeSzr1L(WfuhB&Fzp5i|7vEll&#?AeJA0& zQJk)MW?^UjFD_^NzqmY)GQC3C8lh4OD(pNcE?jv#%!neg2#(>Xz?s6Q^#QJ%ZFIAL z@42kP4gw(4Q|oy!PH89!>TmKhE#J+3q7&M_2-4Q~3ghpOq0jE4p5jrNc~|Jlpo>+5 zhU^6V?Y%)%LF^SS)vI~R=fy&Men!y z|DOx+E?j%@&93U(g++u*RUexg)%RNzuqBS01Wlql?%=5X@+3Cht_!fm{mm$%h_^^R zY7!g5pJeNdtBy0s!!_acw641YpH-q)1KKt|ahu((jV6a9HHRCf97S2*<={EI{P&KrrX|J zUo9`evQ1aV+*f*r)YZ0rc^puDdfH}bSKfrN84$cc$l-BaHf!TbcSghqAMgj4hgtxs zjpqorKTX(kUTAKweH-6@f%PD3_=l)mOb`%wASvoz0VB!r3j6Md7-s5R*x6u}7ge`b z=eIO*3d?1fke9*Z>6{mvAL&nshzU@1d|!c*hTr;sSf~9m)&OUMQDh_^TpN4Mp|f3w z^Xpt=tptVg+XtvEo)%Md0j(9Q&02%uV1!jor)xT2ndpma^{(csV+y7b4rThgc>Fmj zStjz34Nj_)|F1}bg=ykAPtZPp&|j)~U4R~b7Nq4LVCbPBwgDObAoTWQVI$!^y%^D^ zeI-YNk!ce5zigA`oTg~TD$g#&=N2Z$0BxctbaY;t$&~SuMR-2<7U!uJ>0N|>H_$(3 zE%K_LQNGZ#)<1E>Rh8xAB64_-E3C|P zC==7kgG1SCP&pj!nszJYul&u4IS5`>?S4iC#_d1-sE5X9{+qexTJ2K2<{86%i$oJ6 zjIa&==l!ArB;WW6&a)g*r|_P~Ku-xt3S50zGTo_>QBwqe^l;Fkio(Ul_sC+9^cY)s z(L~|W_O2_ERJYsZOM~h*4Ah062N9B(A3)9z0s{@^e^ES>Y_ph68k3gA|L~7v2lIc4 z9un|&`X&CyxqIJ{h&8#ZWHcbqc?Bqi9+Dg_?lvd=NRK% zPmfaDb0eOAOdubb1>C$VMFn@jtyFV)4N`b+_bR3;RBtkGmia5=n{W^k$j_c$Ktf7s z4NKi2_BUp_2D3sFNR*y~8IEr~JM`?%eawGA0~1wcaUyjpBbeMttDdkAUNT+Wh8T1Y zhUbe-`#usCyk;~gy3eb<99tEiZD~{PbdV@0CA(K;|nzEi#+5cB%X@IdH~^dU;u_dBhhOi}K-c#;{p9xX+Lb)HUg~Q&HH4<; zcuJC@Eoyyzj8xF5lV@<9PU>_|W)g|$hSG0A&7LbTL0+-$Pd z;cCqa>M3=jKWB?3{61l(`veRcU)TqMkp#tJ!o+H{)CSShN-9G~^T_xCpQE*s#1t%6E26Q=OEw-U2`P5C7c zCx(N+jO#lF$h8P~s~eaaJaMDK^ZWHL`AY>CK&t!~Rl$SSRDy6Vj1jzZPDAyub+uXP z6LZuTbt%6W-~AnP{gz?=5MBc(WmK~Gb;jIBz$E?+n_RJDX~#%bcOVEQ?gUQPJJlXLa7dA zSt0N)JF3-BI`wBkA|TT~1BMBH)4#lznZQvo-n+L_S)=GMFiZg1X!jwAWPi{mp8=#A zlyn!er5A%uT#40rO#Yn)!vumXV99IXgohZNQ$<)1n(V+n{mD#YRc@oG7Fi6Ey5zD* z2?hgCZp&r46Ypi@tqQYf{`B?r^=y}s;CaHal}V5^99mbt+4X5eN+7&eu zG@*9gW;*)=3IW%F3+zqGDaak+hus{Gls};nmu|V%A4mGah5!a`oF&Q&=p_CHv zvXqJW{WG2d`2KRQ_WgHCml)+bkQB=l*`i%BPn)mf_H56KI4wVJC+qyzSEaourm6Qi zKcBRjh@5(pIypva{EW#L7cx-$y@aqXN0gp8qG2egP_rp(FECM1_s3N;9M&9%MHRJ8 zpQlYGv#AEjE}(#s78Xzds8xH4x4yBlwu?7g(qz-RW6s9%B!7gMNv28$U^u%XTuuxv z=Jvtf_=xqr-!DAYPMS?epBVKB_0!4xgu`S<0x%oMw=Y~QnvBkYC}71YW1}+-5zJU+ z`%Iu?J6o9MBGzTIO{JKeGAuTfDOrZ|bala(sWulCt=PGlsXA?KcH2Cp=38vhUSK)! zA}92syYh$A>Ad?v)W|yhJ@F@r3YCk_`X9`#qbZ<*Lx&K(5`swy%806!O zzP1+F{swhl9y?`{4oW;*z8IoH=w(%&4xlRhs)DisS7gHGC_D`I0ak4Kq6s?h<8L{) z#=Q>TM(APinsi+6UMMvip;|8(V}T4s@s z@x(YTtIa0ISz=RXu^MUWx5GEp*^2rG6kBXcX{=8UNx4xd9r%90r{(RjHC8uI+CEzW3~UjPenhFHV;2HtMgt$jRjc zPM;c68A^vKzo$hbH}4`Y%pBS`2(a-MUHcOF3oUf;4@e-zLYN?^KfG)j7U&`Qh*7Dv zw|p+>{W0q-8~buFEh_9qmk^hNlG>iLjwm(@OjrXXgFIo<#S<_gM*IlO4j9Q znURgFSkbRs?+HjI9W^`{K(AzV|Fcrx-jINJI!Y$(|DfP^lyW{NhWUI8s7LKf zyJ_dFutfR!nPu8fi?7p*n$ZtWo6eq=S8ufEX@Svl{E3Fb@H$8~tXz8nRE7Up2B zUh7im)cN%=MX#wzW_|-WX#*59wlFCjJ9Nxovy&}Stg5OC`W+0129!=}#bUKyX|PCm z2#|`me{XU6ZoDmf`N8T4FM!xT3k?uSh8t8Q^(OGjOdXuaJQr*Qo4r@%pE)#zCP2-$ z7-)|gAS7Z<3D)En8Cw_`NPd16<=IHH;3n(($S^#!KP6Nym0Qqoy?1*$D-V&hl8d&H zJkzUp0=Juff`U*j{r)88rP7BjSI7uq|K#VA(@yGUnB$2fs<+QmL)ou!ouT_U|8zQ? zEt<{b_PHDAC3{c}v}Vu4f;*u9D3=rpJpN9c9Y4$}Q-o~z1z5V?=e=y-YGRdrz56rb zMh&M84n!srS^J{WNO<#Gn--ta%`NP| zIExC;Gzq#g8Ccy{Hon;A7(+?Zg>z$i0FIejvg1p7$(qyRE#xjg3q63c>iit!0K@P{ zq0Qjqud$fx@4qDSsbK}Ta{aK3!8GOtYPn^Pcr6uAl|#ACS5}@=y6&s&{NBb8lYnO-v2@yiY_+@5`Kwc(ozgFzwb(v)EE9nR-n_ODj|bor8{Q}v6K_%S#KOFM zE{yppHEfLxRKy*q(#(|wbEc;52oq}~-zRFHBu9J{tEX6O={qVmzbG}(8|rm;RS)>q zr&$wN1#jP3K1zjAc{g8739%p7jFmB|bpK7D9N`ax)5FtqFuFTm)T@8e<#gCuL)K-? zDl$?1>#ahIoW?^lychHfZRl(j-R{%QA{ znn*U$sh5Pv_ivYJ3Z@;Z_moQ#clF;Q`sPejB5%{yj`$Gn2<8o8E;-t>w(Uj(h}mey>K(Y*$%c+2wJ;9qOsa0#k0OP=aQf{iP`Q#;8u9*J4y}eMl?)tz9MH3~5XZz_w zIG#rv2<3zX;^bs;9%``Ey)BRqcAlCZ*!y}r)! zb?Y(icJd35`a?fyy5N#5>y_+T_aMq8P{FfshY4w)kjG`jYphOZcbC>C=E8aj1JmAn$=12!gokZ`u!*_x6=ZtWu~9ZOk9E~Shb z+zYz8Qi53-fjqVwK80`^X~3xMuvNS!iC5rqUZx(kFN z)k&8cm1i7o+xPAIs_lI1Oxs-6q9Idi$qM&fbj~;5?NE>>_KSAguk!A_Sy7|6P#y|i zxq1a3$)W8c#e(|o-TpCY7W1>GYphAGi)|X&LH9CFEZg(RYpY9NKrJ|5b>xprh9d{b ztoeO;MRnKN8StT(YWLSF|7@%oyMKLx$??8q+GKOtu6Ez>oOOPz1C8L)VB~R*=NaYn zYDJqRXC{^8;cAxioW$hhrne+jW0D-|RoW6py0syV6up zH;;=yl+>k;BRnBAyEK4=1rwnVC(r~BBP$``bg6MX`m-8kZMi$FBeAA$R}|!_vyYKjNOUI)gR7xwi5lwkWRt@Q;OEW98`7%t+=$0)_4A zCPU-H!)3r$`MfE4MR^GtRA(hfvzRsnnaZ@WFYj{2gI3-KX0Dm!|7ZDj!$4enC;fJ| z{+184^=7Z!uML`2T8a-`Fwr-InbnAPg?;{@hnL~k71R`!6p$3q6qOAi^+yf^onptp z>%<*D;#$3q+v+{%d+Ssl9Y!#@9OF=~8`pt-BpyN*R9;j@(WF9uj_##bXDn;AC>-yQ zY$8!hXSi-A=;*UIxe7d6aR#V9YSPqjMq?>rkS`$WFxMQ zltxRXO>gT_o+TI8Ng_PKitB0y)nN7Yu3?rN}9RX}`48@5A@>f2LPR};g=QrSXtJ}Ntw->%yRHOKori%X%cX8rWp&BZg% zaD-T0f@PkpSD0Sjbz0K*p_M6|_UevXr16~XU1%NK9>-V8J%x}D6ytKh?Auyj&%Y^?*&7r7A5OvcJZRo9kTKn`<#$Y^y1Ssndbbaq8gy z#1awI73r#{JsT)YVu!mjc~@e6VqYk{x5IyD-;%P23umhYtEU+vArw%s&ap1+qq+(< zTct%}UDi}h6dQlT`){CKzL~pjITQY(_zl*lO%LNIBeC|>_M!(xly^!?rxpP6!B52o z4tTOn38@cQ&NJ7n2I2o-c1robMghw#i=Ty`9-G+)42k{o1*KRS+fj^p6o7jDQhNvh!{q(@j@}{m>|MyCJC?F1PQ8SvOm{^m1GK z_DucJ&gdu~#(w!2ad@UVu>s8qrxYp6{Jp&Gf@Q^MUOGChy+-aGvN8qM$ars$DJzNb zW00cOKwkH3*QDUKY3tXa{59#z^Y~eIceofEOSEm}wIAv1n{)ngcv}I&GM*Q?<8%0Z zj7Qz;gwfm5PX;Y z>k|p`yS6s`0}4Ld?~_wXutB1niL#~XK#Qo>=_gb5MRHB%^zqlBo~`%CgHCqPI_}-r zx5>NPC1Sy+?XSN;@g7dDb)Q&I{PEkP$&4!I+cIU#W1v2%+qG`DuMas2C{TQ$l9d8A zGM_x8-#};i>5|HXh0n2&FYSKRQuomMqG9#HjW^>Ed)6HsKLB62&%oc^aZL_X(t;Z^ za(Hj);Ky;twYeOtj%xfzd#xGfc*%DTdV#3B#p2TrK@ootDxgG)ArB--k4d=SRcF7_ z$AXvCzIbOE2Cy^k???Q9m|`%S>f6l&8Io?^ zPpj(8ee_@CIvPh?c+1&hu5Wc%eb=aVGJ$bgt92z|%?zfR8Vokun{#P-CDg8iFR2>n zfHxbc3FhN{m<#RtR0@66YK^?spDNRpMZ!A$+e18UvqUl>3 zeV-{m+1T@|qZi6a8)N#IQMjLvF6KEKk~xPDO>IFpNOs+jzj(U`oi1aSd;xS#hl0F0pla zA54Ys9;Q=9@yq*NhU>*jy}5##kWlb<$EolA@+yr$d|{6q-whfnsHo^H^Mr?yk;e0V zy<}~_ZB09J$oZ9;G*!$<)PMLLIW2SxOGQUtZX}-b5R?BX>KaX};&jumXLYP}whR?K zTF`}wa(Hm3{aE0CJr!`0e&{?+WR;tnBzrMZzSey$Lp{oMUiV;h$WVMSX9nAygTIYL`uC0Dui8fnA}EA(?pG1 ze`M52Lgs>jif3c4AENj#Mn+t>uyZV4Ytx4kiOQMWo@`yt**6_Ew^8mUJAu^^H#bdk zyE=ct{0~{={~uXauFC_@rNv2*d$w{qcx=m~WyQ9sv7PU0T?IZ?$uwbNAMAi4d17)# z%a%U)C~5E4?(3F^r`WTQ(^eOj;$dWO5*C2GiY>Z!{8lbkL*t$Tt(kiO1KI8_Zc$hw zG~EU>dK6LBZ8kNN0GAOlC6c{8qAc|BfWBcDxyhX&snihZ$qwQvR?iN;BSt~0*(ppi zw%I65RBYU=gN432271haBY{SIUj%w`Fg7w9{C+rYCtv@{eaJ(?1jX~*Pc21UOibUu zy~98`_NAqzxw$##fCCU8A27G=k73|8Fzvv?gxgX5gG#8S^b(>OoSX{I8(E{IB(uBW z_R_9e4#Ta6{o~gfSH=!|%sHStJMBXc5fqpx5g-ynAiXz!j{PhtR1dGjHmr~&NpY4a z+H<6oI6O#*SF#PKqdnXcqD(r8$99wuktL~Bq@|Lyo<$W22_j~Ehxy`1B!s3#Z3oo$ zTun*4!NYy?e6{mL%_V@n=E^noYSZh!I>qf+t}z>${26|}@tM;7ea%MF0&(Vo3XGTx z-fpe<^Ei6QxazTHJ{qnucP;s+Qd`*T_pPYwFY*#or7KOz&aV5b_AlRuv&QUf#p&v7 zLxM*bJ>KLBDX6W}Xuc1uqGihmVCC26>&@i=1O^5IoAeI%W2L}4U;>%kR*S>_W%ITzGo~DH@3-r>>&_?LM$N3u%qYme z3kw&WUP&>$eJcqxLo>41<0$#L_xN5rIL$`fRu5%ZMSpoX4 zU5{?pU0(=akG-#Gu?>hf5NezJEo{Rmk-D`fZAS{$}KD-)eB>`i_u84Eci^d_yJT_shJ_gF-PWf$z#vZ>*CWM@G#nG^34NZ?h%{ttpJ#!Ngd38TjZTlXj zC5Xl1vUa0+TAp1MNvZkWCVX|c-@pP!=$^yd60Nx-aqHh6uG^9B#J?-VxOTnNo&o$3 z$=2K3RrI`qle8XRb@7N28DSeRLZp1 ze7i&FSi~EM5#KcrZQdt`PV|-#j_)g?sQ|O}Y0N%n%y}QB%UAQ1 z>~w8Kqdo_Ip6`Cq3h|^>LeeXxR6pa!i17mlVo_t8O}=- zyx2DYU($W-a`?$vgOXgnV$N$6g(&8kQX|18D`es;&&k)}y}Pioasp)WxSlQ4hKJvr z*o^*2oWuKi=9|jqDJT~&T($POpB+p}D)v5~uXzsh!Fm!j8mwQnmeaHIP~*^}ChyN? zE~JW1IxGrBuzH|JF{^M8B_9I#J06+{9R~`W)|8iYAtff(*lK}>x$1m*d=)*xIRXB5 zvK@WhKjEb|EB$FY-##g5%=eab^dBUJxzGS&wZN< z^Kwp^PcY~~FJjK!QGzDda`IpP0<9DYq@ahjCBkn`UTuZ#RE9gkL|3mqh_yA+S3?B$`Evpob&O5ggX6 zBZR~~Z9aem8q)bw!iqClhf>bw4+b&orynynK;PK-`s75sRr{9P&ZCuquiO57Nx5Qi zt-j=I)hyOM`xIxhacwRbPAy}SwI+5GRM^_xUQI^#O`Q*4mNjMcux`KsN08je0I_Mk zD)$o1zZ@Sri?j;AP!q6s0O+WFKft5LV@E$?1jN~`L6$loeOW)5;h`RJ*}O;cWxq{T zs2m~_Pq>|Ek9HSqfeZk;ZrBD-+xnkU>q3*lhvj&k++H6yJ}iuwet@se=7L4xhoLTy ze;$o*bGscmBR*r=&0`}fx!Vd-T$ONYlWH`>A|%v<3N&r84+E$xRj|C$2S})J&;!?t z6JX+F2?ih+`)~v!FwiMm$lThezq2FD{qChU#s+|X6Bqx*APGVqIORL}yRUqjQpVLN zT)Q%O*~+;~L=8;cPc7wS2mqbzNgX>I4PL-haKda9;mzs*AwZ^zcBFEJjM@oTL^bjd zWw<#|%9I15e0IYdbiut|!=G02r!-Y3Op9Y7L8rH&9J6nK6DEqR`ipd@?P_3PrOO9w z)mjws=C8s2(d6{?X2nQ~%6`}or|S9P(6WP}q!sGvUZ9MQs>%R0pQ7VFKYBlSW{N7* zmU&dB`BQT0_+F!La+RN*e%;|8@gqB)x4*HvHNKYvlC0zCU`R-9i6sx(^c6mD()_!j zCG~zEQoO=+awIg_DKBTsy6>xgooLT62;=zBbqW8WAG!N;kBVu2e(|N1Tm-pKbG>Ys z15#lGT3M7peWfmZDoPojfHMnU-t0!ov~&j$xIkXC4H5oI)uiBr=7#cZM{f)+SX-n(^jQXu%Dd6pUOS^OlUdUa=AM6=daq@zjh-b=$?7%U#lq#Dl zClVTFbV3Wq94e;>p>am?5)W!i|}-!kC}?3sJ2>w~p^=i+dL{E&q{Jzrm3UQQS_MmMUcsFEydYFksLoPP2*4Tj#c zB2;beZ7Ef!dL{*!v;9D~oVB3x$T*ul2jy72;@P;ufW0F*qG7M9e+iy;jC9EH5L&3_ z9&JNKP^DM5jBDCkHK>>shd$lzJRbdcI`SsWfj9*y2g?Qi}WFjT87sYF%0mQ9J5;e%CmN>>Tz3{RpIZzp0{5iEM@eUZ8; z2i*eB9cqj-;#Ig4*8ztMfsD!pn@Jq!uP8BxTkrb&O zwGbI#Ss{1K0^Ndh^mY-ga5S9r`{gowB4^Fl(hv;Wlw}hM6&l?)N|)V+bJ7@D=)aV| z`c(Fv1hsSNsVAQhXLKaDh7;WIhmn!7^0BKUBx!_iY)}|aM2!qZ`aF?WPKnTJTNTD5 ziqbo7Rg%Zkms1bcDF8hKwa5>imyeUBba?GtJdJba=pW!k zddt5gpaK>Lqf8M?Z=p{GDH_Qm$$UL{3am}*sXhO=7AQMYuPz4kh)ZP6mNj^4aL1*5 zixTJ&=hr~iFSBv|>He$Ms1r<7nKtV7brKC*zgoNV*uBM72m+>y`RcWyYGLz>VX8&T z(E=5lr05K{szR+YE;@GC^XZrQ7$I~(<*)63t1pcHdgy4Er(V8{iu=nQiI0AT=TUGj zS%z7wIY?PFyE?b-mres`5-BGHb-NGK*^jH5PU*bFxy0r&HkBVmpmhiv5E-!cCeBN6UQORSGvF(z(Wg1L)nH247%e#=t8rxY8c`ShDErkuHg{ymR zh`!?dM)dvIwX?~P=qnDA-lqD^RQLw;u*?kOS*9Je3_0GteMV;(rNHW9=XuZ2=ICm& z5V(H$=>L{4K#Q#g{@b_j-^Z^fn7}~}tiugZH}CZEMJTD+5bj=Fnv~ss(Q)WJ{m#Z9 z=)z=SVvsbIr$_>|V1iqDGrheFeH=sI%LfYQ2nCvc1DnoO-*abeQ1-Q*$Jf?~JFIpA z%LqU0^UTRu#GgxALkfEH|A>e(Sy0A6Q}HZu%%AdD5)t!o{wT|QV0FVfWI<}0Ko1ev zR1eO*#A9cFF=nv-z5q zpu}XuJ;B-r+oe6s+hY8otC}u?vHP&4F2^>RvZyOCX^dxIEJmmE)}m8YLJ3vicNQEc zZ~@h8-|d0rJcRGp70al7@;7;+@T)_MtLmF^OLc1Ls*qG2=h1!^k#5mbbqxr?H)-v6 z=xlP7Hpn78`2n{xBy5d4!zIR6p>?fYo1W)v}oxVv1VZLc{k`*Y;}e2>VXSb_tMNj2dp^W%sX{$b29Vp zvL9(dbzjG9k2Z}?4$ZC(M5lC+wt0RRq~5229IYeG5Uah3RqD@;RRqmh-GN+(BD1k% zhQ8J;uG@XgR@vi^4@I0WBwI2pi1&aqcZ*(&SAiTKHT-HIBbG6)Cml2Rz0`TV<^ol4 zB%Q9!*``Z}2=82=tXgShBJOQpjQP^xEhKri-nh>mIur|xi$?Fd8|I+|5WxZe7Hnmo zed?mKy}(HB;R%77W+`D0cn}}@R(!pQ3QE@jM*AlE!mnH`^k%=mPLJfBid2r>>)eoyn=t5OG zw#`0xoBvKTj-*H)BLY4o4zFqroj0S0X|3+2Z8RddM-+jN57BxRSO9)38&CTyJT^CD z$J6M`*vE^cNo^M*_@^sXFg|nu_m;bE@l*KcuioBYy}NJqOiIl)eA=2Twne~eT>Ohx ze$(WxT6aCJRp@!VE@blrR-Z!%@b}|?qxhE;F$}G!`rP~kKJyEb;F3M|u#3*H1JCqb zAAM?0jW6TNDo`+edXUgoQy9@0j{m=_?F6IwUY_gu<=vGHNt0%b8tY8oX-_-ogdz%N z(tMKA{#zuHKoFxuM$vwH=s^0vT+X~MpLaQa&yjkW>$X3CfWI*A*ffIzD+w6vzTSkK zk3KI3`k?#<{V=3BY8qqxO9=c)0;EV4D5VK>Ul-V7eg--qy2xHtgzgr%astSK-nu-8 z6i%HXnc*ctYZ%Mx-V{#r6Y-P7rWbDm8{Xm`CNvK}LOL{T*mE!iJe>dUxD!7EqD zw{Oj*Yo<%0qvK6D`;M~n7M+5agv6OV-J?84UcuxJE$u{IAy$aerArc`^r&0?H^IER z9VDws*w5s9`d=RFEA=9+{0`&yZX?t$se2xTjj&5>C5f2IB-=luu+H%0G|vr4b`*x19qB zOq(*eb!!R`bL65D2G zOD}OMFK53phC02MUO-JL^FBDA!0|JMl5ykY7OCAdhP7%jBP;}%rt1jAvhpg^ST*tn z0teakD0#G+PN%m{-KDLoHUG9(fON*5=`g=qibq+%m&9Jb^pfNwjm5@scn6~p9gNJ8 zRpQ70n~%4ATt-LHH&J8OnW3rvJL=cd)8V1M z4gxHmymf4xz)bPr)>iF#&!>7FP1?x9@Ug%>CG1u{`_)ROiyIePrS?q_=v7T$$rCJD z-&V5u^ztv2i%Q}`Z{8OC6&BEZA`ATLM5*p@9;kR9TOL?5EO24jE!r#jAq~qtJ!gz_B?X33NCG$!}#d}s=AE$=P}d%5pabHF#z%l9;%M3qPisS95G!Vs9bF2rq})Amb@AN?+rt}iHm z^xW^(J>}Qkj?ez^F`PMTyQ3iS({lU23X@O1WCd2#H=6VhIsu!T5LCvpgH;O32Buy? zQ11{_kb=+~4p3wxRDzN@R7$~`siZ*=CN)8p5mb>uHJ<2u@SkyS`iBj^Z9FOrKmZ)1 Kby??>&;$UlAY1zY literal 0 HcmV?d00001 diff --git a/docs/validation/quickshell-dock-tela/result.json b/docs/validation/quickshell-dock-tela/result.json new file mode 100644 index 000000000..34ebc1f37 --- /dev/null +++ b/docs/validation/quickshell-dock-tela/result.json @@ -0,0 +1,225 @@ +{ + "status": "PASS", + "initial": { + "ready": true, + "ids": [ + "firefox.desktop", + "kitty.desktop", + "yazi.desktop", + "nvim.desktop", + "org.darktable.darktable.desktop", + "orca-slicer.desktop", + "org.freecad.FreeCAD.desktop", + "org.kde.digikam.desktop", + "AppFlowy.desktop" + ], + "error": "", + "phase": "hidden", + "revealed": false, + "entries": [ + { + "id": "firefox.desktop", + "name": "Mozilla Firefox", + "genericName": "Web Browser", + "keywords": [ + "Internet", + "WWW", + "Browser", + "Web", + "Explorer" + ], + "icon": "firefox", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/firefox.svg", + "aliases": [ + "firefox" + ], + "newInstance": false, + "category": "Internet", + "pinned": true, + "windows": [] + }, + { + "id": "kitty.desktop", + "name": "kitty", + "genericName": "Terminal emulator", + "keywords": [], + "icon": "kitty", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/kitty.svg", + "aliases": [ + "kitty" + ], + "newInstance": true, + "category": "Syst\u00e8me", + "pinned": true, + "windows": [] + }, + { + "id": "yazi.desktop", + "name": "Yazi File Manager", + "genericName": "", + "keywords": [ + "File", + "Manager", + "Explorer", + "Browser", + "Launcher" + ], + "icon": "yazi", + "iconSource": "file:///usr/share/icons/hicolor/48x48/apps/yazi.png", + "aliases": [ + "yazi" + ], + "newInstance": false, + "category": "Syst\u00e8me", + "pinned": true, + "windows": [] + }, + { + "id": "nvim.desktop", + "name": "Neovim", + "genericName": "Text Editor", + "keywords": [ + "Text", + "editor" + ], + "icon": "nvim", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/nvim.svg", + "aliases": [ + "nvim" + ], + "newInstance": false, + "category": "D\u00e9veloppement", + "pinned": true, + "windows": [] + }, + { + "id": "org.darktable.darktable.desktop", + "name": "Darktable Photo Workflow Software", + "genericName": "Virtual Lighttable and Darkroom", + "keywords": [ + "graphics", + "photography", + "raw" + ], + "icon": "darktable", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/darktable.svg", + "aliases": [ + "darktable", + "org.darktable.darktable" + ], + "newInstance": false, + "category": "Graphisme", + "pinned": true, + "windows": [] + }, + { + "id": "orca-slicer.desktop", + "name": "OrcaSlicer", + "genericName": "", + "keywords": [], + "icon": "OrcaSlicer", + "iconSource": "file:///usr/share/icons/hicolor/192x192/apps/OrcaSlicer.png", + "aliases": [ + "apprun", + "orca-slicer" + ], + "newInstance": false, + "category": "Utilitaires", + "pinned": true, + "windows": [] + }, + { + "id": "org.freecad.FreeCAD.desktop", + "name": "FreeCAD", + "genericName": "CAD Application", + "keywords": [], + "icon": "org.freecad.FreeCAD", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/org.freecad.FreeCAD.svg", + "aliases": [ + "freecad", + "org.freecad.freecad" + ], + "newInstance": false, + "category": "Graphisme", + "pinned": true, + "windows": [] + }, + { + "id": "org.kde.digikam.desktop", + "name": "digiKam", + "genericName": "Photo Management Program", + "keywords": [], + "icon": "digikam", + "iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/digikam.svg", + "aliases": [ + "digikam", + "org.kde.digikam" + ], + "newInstance": false, + "category": "Graphisme", + "pinned": true, + "windows": [] + }, + { + "id": "AppFlowy.desktop", + "name": "AppFlowy", + "genericName": "", + "keywords": [ + "Notes" + ], + "icon": "appflowy", + "iconSource": "file:///usr/share/icons/hicolor/scalable/apps/appflowy.svg", + "aliases": [ + "appflowy" + ], + "newInstance": false, + "category": "Internet", + "pinned": true, + "windows": [] + } + ], + "theme": "Tela-circle-purple-dark", + "menu": null, + "searchFocused": false, + "navFocused": false, + "gridFocused": false, + "screenWidth": 1280, + "bodyY": 364, + "width": 538, + "pointerX": -1000, + "tileWidth": 54, + "context": false + }, + "reordered": [ + "kitty.desktop", + "firefox.desktop", + "yazi.desktop", + "nvim.desktop", + "org.darktable.darktable.desktop", + "orca-slicer.desktop", + "org.freecad.FreeCAD.desktop", + "org.kde.digikam.desktop", + "AppFlowy.desktop" + ], + "checks": [ + "shared favorites", + "menu and dock mutations", + "durable reorder", + "empty restart", + "autohide", + "context hold", + "kitty multi-window", + "floating scratchpad explicit focus", + "window close", + "no hover focus", + "real pointer drag", + "zoom growth", + "IPC reconnect", + "Firefox private profile", + "desktop install/remove monitoring", + "external writer", + "keyboard menu navigation", + "resolution change", + "SwayFX dock blur rule" + ] +} diff --git a/docs/validation/quickshell-dock-tela/session-compat.log b/docs/validation/quickshell-dock-tela/session-compat.log new file mode 100644 index 000000000..aef5bae2e --- /dev/null +++ b/docs/validation/quickshell-dock-tela/session-compat.log @@ -0,0 +1,18 @@ +/tmp/labfy-session-v2-TEST_ONLY-43bkifog +ready PASS +accept PASS +cancel PASS +timeout PASS +absent PASS +still-locked PASS +different PASS +quickshell-restart-during-unlock PASS +keyring_ui WITH_DOCK PASS +success PASS +failure PASS +invalid PASS +new PASS +claimed PASS +real PASS +deferred-real PASS +phase3b_ui WITH_DOCK PASS diff --git a/docs/validation/quickshell-dock-tela/session-v2-results.json b/docs/validation/quickshell-dock-tela/session-v2-results.json new file mode 100644 index 000000000..316f4fc7b --- /dev/null +++ b/docs/validation/quickshell-dock-tela/session-v2-results.json @@ -0,0 +1,13 @@ +{ + "backend": 0, + "focus": 0, + "qml-unit": 0, + "qml-qt": 0, + "keyring-ui": 0, + "keyring-runtime": 0, + "scratchpad-runtime": 0, + "phase3b-ui": 0, + "p0-runtime": 0, + "path-runtime": 0, + "diff-check": 0 +} diff --git a/docs/validation/quickshell-dock-tela/shell-load.log b/docs/validation/quickshell-dock-tela/shell-load.log new file mode 100644 index 000000000..988400499 --- /dev/null +++ b/docs/validation/quickshell-dock-tela/shell-load.log @@ -0,0 +1 @@ +Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-rfgtrd4k diff --git a/docs/validation/quickshell-dock-tela/summary.json b/docs/validation/quickshell-dock-tela/summary.json new file mode 100644 index 000000000..6a28b808b --- /dev/null +++ b/docs/validation/quickshell-dock-tela/summary.json @@ -0,0 +1,31 @@ +{ + "status": "PASS", + "baseline": "a556cd56", + "quickshell": "0.3.2", + "sway": "SwayFX 0.6 / Sway 1.12.0", + "tests": { + "existingApplicationsPython": 7, + "dockPython": 6, + "existingApplicationsJS": "PASS", + "dockModelsJS": "PASS", + "sessionV2BackendPython": 93, + "sessionV2ValidationGroups": 11, + "keyringWithDock": "PASS (8 cases)", + "restoreUIWithDock": "PASS (7 cases)", + "headless": "PASS", + "fullShellRelativeImports": "PASS", + "diffCheck": "PASS" + }, + "activeSession": "not modified; original nine favorites preserved", + "limitations": [ + "XWayland fixture coverage only", + "physical greeter/PAM login not exercised", + "terminal-host identity is preserved", + "fractional scale and touch not exercised" + ], + "notes": [ + "Absolute-import fixture emits QuickShell scanner warnings; actual relative-import shell loads.", + "Virtual keyboard teardown emits Qt Wayland leave/text-input warnings; keyboard cycle passes.", + "Full shell intentionally has no system services in the private compositor." + ] +} diff --git a/docs/validation/quickshell-dock-tela/unit-tests.log b/docs/validation/quickshell-dock-tela/unit-tests.log new file mode 100644 index 000000000..d20a9ce73 --- /dev/null +++ b/docs/validation/quickshell-dock-tela/unit-tests.log @@ -0,0 +1,11 @@ +test_inheritance_from_a_private_test_theme (test_shared.Icons.test_inheritance_from_a_private_test_theme) ... ok +test_tela_desktop_icon_and_absolute (test_shared.Icons.test_tela_desktop_icon_and_absolute) ... ok +test_nine_move_missing_anchor_and_empty (test_shared.Persistence.test_nine_move_missing_anchor_and_empty) ... ok +test_real_concurrent_processes_keep_all_additions_during_moves (test_shared.Persistence.test_real_concurrent_processes_keep_all_additions_during_moves) ... ok +test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list (test_shared.Persistence.test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list) ... ok +test_wayland_xwayland_scratchpad_and_explicit_focus (test_shared.Sway.test_wayland_xwayland_scratchpad_and_explicit_focus) ... ok + +---------------------------------------------------------------------- +Ran 6 tests in 0.705s + +OK diff --git a/docs/validation/quickshell-dock-tela/zoom.png b/docs/validation/quickshell-dock-tela/zoom.png new file mode 100644 index 0000000000000000000000000000000000000000..f22cb06b29152724683ee936aa548cec0a4c89d6 GIT binary patch literal 37739 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 otk4<9|Z!) zzeZ|yQIq)4M~(KU9xh4!3;lxc?uH`qGAxz`j*xmi{;bZj8N((@**L-U%0nKSo8 zO7F;taCEqT-A`>8TYBvc)=BH!I6cZ}N~OWB?B_?|4_#m@m}|>e9AqH_u+XDeEPeg_ zKtQrC1QXxny$RSky8a%nw|sTpTnS|qkdNw${zGJ+&CTtD{*cW3Yg|<6!XFO4z|+|u z`*nT%6Qy$bl=d1jgnhkWk{o(Faxd|kSpnf)k=>Q77TK;oFe*YLG+<=Et>o-&vwxmP zih4_cM?jP6r_1PY_K1Qb>n-sQC1=pqGaVZ>#;w2&s&T_n=60XxW@s05^yQi_GxOl6 z?0Q_AZn{?=9Fh!3by)i>^6hubf}tbFV;yyx!^nAp`+cjD@3tuEgIzM6;okBi&>B^5 z)zS6gB#>v?Hs2H}gR9g>Og!BEbKtW?7lo=-+e?{rb+L)+iOMgRCPCY z)@ApOD6t7u#Zr$?x`ENvQy{7|wsVKwk|?uG^zsn=4U2wa1NS~0AAc_yh~j(A&h5x0 z4~6>R`d2xE7*58=V}PNpA1!ZjQ9Gf+xT3?ZK^H}nNMLJ<1Gzc~^ZmjH|wAIJ+CY#Y_i(Vet52=&L}8m~2(v!oNJU)RZEiWT1kgD!rfKvRJPB zs>^Z5g5Qjnp?hE&I0W-uxJp)R?GvXmw>DNh|9(wkyeXm_Bp7#ZWhtVFP}m(k9{nns z0tU^4@JGNTyPr*%Kq;4qsV2N_o zR>{yRh(4pU*ZB>0bXFmEljHM=2<@FJ6!q(4aS0XVbXjh0@-_it!TbU@7f5_K1^QTy z7CEoYtKD=5vFr(KLY=NJ!f;6nRXoVe!EMcfvU~xQ77^NO!Y#XW938Dr?pM~DcwsZk zfJ?V9)xVLGHnw&$Gdoi1vKv?_*1NEqol^Rf0qext8MgcZO*^Y5>-+k2Q z>ZTcPjtu1(>4$dnQ_*B0$RQQN5vrRF*Lv?ZbIg;Qd)h~-2*=)D=^`(}DZVu4ENEgF z-Shd5VKa4q4PEX6|FN7uTws6%a70&GuRe9T)y5L58=8xwRS#NDK1%NXGBEM-vV?{P z+m(x695dSF0|Q9Ja-N$`l~9`KRmp(zHc!lKFNB<|xRsFBWwK3RoOUi|X3L)F?V*qV zd#ilp?QZTDM$SemI=n6Rx3dZJkT2$8r&nrBq@`PhyyI~mPQ6M(Nvv$NZpR$TL-_*A zdcgJz8z*3MUv*RHHZC=`p_=@mYP}R&+`}ues{gu=(cu--8xeNMntUpRbReE%COz?1-9M7XJWNhyRsbqPmtAUM4#OU0u_UmpbaPe&nmZ z6#a3Jv!j5W{c=i=@+_XHCy;@h&|C;G=fVf3L;j|n$349)N<(VLOXIHWL$Wsped-R} zW8cIlt3Xbt^xWdjVEwCUw>E6uevi@{PVGTYhBm|lWT;ciO}N`kEp8h zh$7s)3gElLyGJ$w4#ZI(;^u|@BW;Cd2%!tp4R0UEby85WkDB^~Ho>j>Z~74~*Nh;C zn!#{0%KcL9Uoi0j2O&GMFu2Msp#BY;LDpk`1R_=`hLpZn zopvFB?HPnC^5&@fXeRln$YorSI&UencrqYT&$syE=-y~rg}sft!tCUdNI`_mo!;`hj0a-v*NRrz=iy{oOZx@P;shJb>Li;AQh58(Sp zIY2cwaC}#jGmR3l;hJkGy|RV2CEZnIqdeZ&*{Q^fYc=IX`dUQioAS5s`l!0!Ea- zgi(zdPkPwisWe%8q!N#eHHyo}Tk}u?%K#>}hDHSx;w3JJtNX#jnMABTXNHxEA+QdH zGBQu3tQ^!G(N$?_XluV6KwX_`bN@y$)ZD(=8>3r;6p`0rF*=IWZm(}`wtqZ{3zE&^ zHm%_MHFr72Dk+a5@*+(pfh87|k<8~{_Bp@XXWa?>P|#e(#Nxld<5gh(?b~sn&(6%z zY0EX6n_pLZXYGNooLODjv}$r%YvVFaEmSkRC(8qZ7J*G3hs-9Hcx2w*&djWpgNey$ z|M+t9aZe2KYW?#egR3bDm+Pk161bEq3H__$P=cwKENRl-7~Ob$p0~?4FDorAD=S13 zhf=Xp#1gSzb3Ok?Gp7$DuFy2q(b2K7sgV_UOj*j}^Za}VDn0>RTx$}HFaO^iafEQl zf@2ym0kcEO?}+y;B2wiIfJ^NbhTr<&!^Z(!1^_}pzK^E+XTw6^HN|CWX(FV7_6S2CHl|Y9VuStUBT$- zc{mzc4Rel4lRt9zq(>&E(#Q;?h;_eQ{;*TS+U$l;vB!dRufj2)1>U%-?HC-@1nc!h z+c_U6i!$32K20TQ5J#%3Z-pPm#^CFj!+Ym62$?g%xH-+w+7dz7UBx299?^3Cq`Ltn zpwWEM^1;ErUu{VeBH43BVlWMuD{np~tcg!aW{l0a&FluzA9gUY48;J;z7A~fZZQs9 z&UT1VNe2-xw(Hc=OATwA{(Z|Dgums!%ohVqwxmRu3q6dTND5)EVq?8RVB=Yc6HNgT zh5X6P2#u@{LyUxuux0Jy6^GsYMiAVv2fLoAGU||ExIw)aEr%k4iTFyS`VW8d2D#)u zT9x_%stv*4a@~-LM#~WZ00uO|xu2wj_z=QL)V6-m-Ovjl`6@)hNxuey#aWx@K)Qd5 zQhNk&f+iWZ%9Cd}Y}oo@v}GdyVKkP2yf2cuvlY`%dY+Bv@RJfY8~(nbmLLx~zDXZ{ z=qrwyu`NN4EM{x8&mE1S5gjsXE(}5R-PE`ghg6e>L ziX@lCfhjf$QdsczLDVJ|5OW0G>tiK{c1<2%Q4sK#p#L@KtETB~b+$rKtG{)Ch1nw; zQk3+A85W)I10W=oUv5k3+nZ1{(ufJODqbFdG89;S;z07ASR zjTTI1I0AOQmRV|Zc>qs(o0C&;5+-UJ)28mSMDbmWX~!M-y?YBsY!VKi(@I=p4n4wn zdNFJ5oDjfam@ilwo3~!4TQozowi8}%mvMimLtluhba8>5sxL-NDO)|xw-U+6+YmjRR}HKOjEk;k{>J7quS{1IV}`JyLJd6o=lraHoW$gj>hu-`dZQGji}TdE7Q2B z!!-^yMvHA32n1=C6eTI~1 zp7u_Lm(gI6b0rj!rgQhiv)?&}?K%7n)XKLtNRP-Agpa6gR7IwyUin$ab+bAvdy1iF z5rShY(bGqmdVagnb{tj-nsYGnI7Sec#6 zj5=xv)H=5|ldhzs8DK9I-hboQe~k{Smt^d|gX<%sR+f*hQWxmh&B?kVT*CLi;Zj)A zvhOLhB3A3ej!mG`L4_lO&^e7VwxWY11aRoQx{=uH32?Idt4>%#9%QVqmHwE@QQg$Y zS!LhKD_;iR4|OFa^skac`iv25(jgNQciG_ZhNrO-RoKQ3$cc53BpS5e5$^RwUyFD@ zK9Sc{%=vO~Grfge!CgRuT?YxSsj!&D`W*nky_rB6! z$w>_&s{6tYV7|*OXv{8&OVn^=Oxdl@%k!?aZPJxX$cKm@@&rE%{OD7o?Ei@ zNxeXjeetg_os9Kvr_O6e%iFB_bdO&}>bF|pPK$*ciQxAc_pLNzcL<`n{qYg2|b_N;n6l>ha=;3)yfN( zj5$0mavxH6xG2}j?rG8B7;|)w8&CiM literal 0 HcmV?d00001 diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 624bdf4b1..1eda66316 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -103,8 +103,7 @@ PanelWindow { property string applicationsError: "" property string queuedApplicationId: "" - readonly property string applicationsBackend: (Quickshell.env("XDG_CONFIG_HOME") || - (Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py" + readonly property string applicationsBackend: FavoritesService.backend function closeApplications() { applicationCoordinator.applicationsOutput = ""; } function toggleApplications() { if (authenticationActive) return false; diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index 8462217e8..6adff6e17 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -389,3 +389,10 @@ relance n’est automatique, y compris après redémarrage QuickShell. Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les commandes, les preuves et les limites de l’environnement. La recette réussie du 7 octobre est historique ; elle ne valide pas cette révision au login réel. + +## Dock et favoris partagés + +Le dock à masquage automatique et le menu utilisent `applications/FavoritesService.qml` +et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture, +interactions, tests headless, limites et procédure d’intégration : +[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md). diff --git a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationIcon.qml b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationIcon.qml new file mode 100644 index 000000000..998ed7b3d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationIcon.qml @@ -0,0 +1,24 @@ +import QtQuick +import "../theme" + +Item { + property string iconSource: "" + Image { + id: image + anchors.fill: parent + source: parent.iconSource + sourceSize.width: Math.ceil(width * 2) + sourceSize.height: Math.ceil(height * 2) + fillMode: Image.PreserveAspectFit + asynchronous: true + } + // CONTRACT: a corrupt, removed or unresolvable icon always has a visible + // fallback independent of any installed font or desktop icon theme. + Rectangle { + anchors.fill: parent + visible: image.status !== Image.Ready + radius: width / 2; color: Theme.surface1 + border.color: Theme.lavender + Text { anchors.centerIn: parent; text: "◇"; color: Theme.lavender; font.pixelSize: parent.width * 0.6 } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml index cb58f0dae..2e060b07d 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml +++ b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml @@ -12,8 +12,8 @@ PanelWindow { signal menuDismissed() signal launchRequested(string desktopId) property string errorMessage: "" - property var allApps: [] - property var favoriteIds: [] + readonly property var allApps: FavoritesService.apps + readonly property var favoriteIds: FavoritesService.ids property string selectedCategory: "Toutes" property int selectedIndex: 0 property int navIndex: 0 @@ -44,10 +44,6 @@ PanelWindow { WlrLayershell.namespace: "labfy-applications-menu" color: "transparent" - function refreshCatalog() { - allApps = ApplicationsModel.catalog(DesktopEntries.applications.values); - if (!favoriteIds.length && selectedCategory === "Favoris") selectedCategory = "Toutes"; - } function chooseCategory(key) { selectedCategory = key; navIndex = navigation.findIndex(item => item.key === key); @@ -63,10 +59,7 @@ PanelWindow { if (app) launchRequested(app.id); } function toggleFavorite(id) { - const had = favoriteIds.includes(id); - favoriteIds = had ? favoriteIds.filter(value => value !== id) : favoriteIds.concat([id]); - Quickshell.execDetached(["python3", "-B", backend, "favorites", had ? "remove" : "add", id]); - if (selectedCategory === "Favoris" && favoriteIds.length === 0) chooseCategory("Toutes"); + FavoritesService.toggle(id); } function dismiss() { if (closing) return; @@ -80,39 +73,10 @@ PanelWindow { interval: 150; repeat: false onTriggered: { menu.visible = false; menu.menuDismissed(); } } - readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") || - (Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py" - onVisibleAppsChanged: selectedIndex = Math.min(selectedIndex, Math.max(0, visibleApps.length - 1)) - Connections { - target: DesktopEntries - function onApplicationsChanged() { menu.refreshCatalog(); } - } - Process { - id: favoritesReader - command: ["python3", "-B", menu.backend, "favorites", "read"] - stdout: StdioCollector { id: favoritesOutput; waitForEnd: true } - onExited: (code, status) => { - if (code !== 0) { menu.errorMessage = "Favoris indisponibles"; return; } - try { - const data = JSON.parse(favoritesOutput.text); - if (data.exists) { - menu.favoriteIds = data.ids; - } else { - const suggested = ApplicationsModel.initialFavorites(menu.allApps); - menu.favoriteIds = suggested; - Quickshell.execDetached(["python3", "-B", menu.backend, "favorites", "seed"] - .concat(suggested)); - } - menu.selectedCategory = menu.favoriteIds.some(id => menu.allApps.some(app => app.id === id)) - ? "Favoris" : "Toutes"; - menu.navIndex = menu.selectedCategory === "Favoris" ? 0 : 1; - } catch (_) { menu.errorMessage = "Favoris invalides"; } - } - } Component.onCompleted: { - refreshCatalog(); - favoritesReader.running = true; + selectedCategory = favoriteIds.some(id => allApps.some(app => app.id === id)) ? "Favoris" : "Toutes"; + navIndex = selectedCategory === "Favoris" ? 0 : 1; } // The Wayland surface exists after completion; focus at the next frame. Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() } @@ -166,6 +130,10 @@ PanelWindow { font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13 background: Item {} onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); } + // WHY: TextField's built-in Tab navigation consumes the + // event before AfterItem handlers. Preserve this menu's + // explicit search/sidebar/grid keyboard cycle. + Keys.priority: Keys.BeforeItem Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; } else if (event.key === Qt.Key_Backtab || @@ -182,8 +150,8 @@ PanelWindow { } } Text { - visible: menu.errorMessage !== "" - width: parent.width; text: menu.errorMessage + visible: menu.errorMessage !== "" || FavoritesService.error !== "" + width: parent.width; text: menu.errorMessage || FavoritesService.error color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight } Item { @@ -199,6 +167,7 @@ PanelWindow { anchors.fill: parent; anchors.margins: 5 clip: true; spacing: 3 model: menu.navigation + Keys.priority: Keys.BeforeItem ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Keys.onPressed: event => { if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) { @@ -255,6 +224,7 @@ PanelWindow { visible: menu.visibleApps.length > 0 clip: true model: menu.visibleApps + Keys.priority: Keys.BeforeItem cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3))) cellHeight: 104 ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } @@ -293,23 +263,12 @@ PanelWindow { border.color: menu.selectedIndex === index && grid.activeFocus ? Theme.lavender : "transparent" } - Image { + ApplicationIcon { id: appIcon anchors.top: parent.top; anchors.topMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: 46; height: 46 - source: modelData.icon.startsWith("/") ? "file://" + modelData.icon - : Quickshell.iconPath(modelData.icon || "application-x-executable", - "application-x-executable") - sourceSize.width: 46; sourceSize.height: 46 - fillMode: Image.PreserveAspectFit - asynchronous: true - } - Text { - visible: appIcon.status === Image.Error - anchors.centerIn: appIcon - text: "󰀻"; color: Theme.lavender - font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 32 + iconSource: modelData.iconSource } Text { anchors.top: appIcon.bottom; anchors.topMargin: 5 diff --git a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js index 36267f092..e06a09973 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js +++ b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js @@ -31,7 +31,8 @@ function catalog(entries) { .map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop", name: entry.name || entry.id, genericName: entry.genericName || "", keywords: entry.keywords || [], - icon: entry.icon || "", category: classify(entry) })) + icon: entry.icon || "", iconSource: entry.iconSource || "", + aliases: entry.aliases || [], newInstance: !!entry.newInstance, category: classify(entry) })) .sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id)); } diff --git a/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml b/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml new file mode 100644 index 000000000..40fb64932 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml @@ -0,0 +1,102 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "ApplicationsModel.js" as Model +import "WindowModel.js" as Windows + +Singleton { + id: service + // CONTRACT: both interfaces bind these backend-authoritative properties. + // No UI writes ids; a failed command never leaves an optimistic favorite. + property var ids: [] + property var apps: [] + property var windows: [] + property bool ready: false + property bool available: false + property string error: "" + property string iconTheme: "" + readonly property string backend: decodeURIComponent(Qt.resolvedUrl("backend.py").toString().replace(/^file:\/\//, "")) + readonly property var dockEntries: Windows.entries(apps, ids, windows) + function mutate(action, values) { + if (!available) { error = "Service de favoris indisponible"; return; } + bridge.write(JSON.stringify({action: action, ids: values}) + "\n"); + } + function toggle(id) { mutate("toggle", [id]); } + function move(id, before) { mutate("move", [id, before]); } + function launch(id) { act("launch", id); } + function focusWindow(id) { act("focus", id); } + function act(kind, id) { + // INVARIANT: callers are user-click handlers only. Neither snapshots, + // state transitions nor animations may call this action boundary. + if (action.running) { error = "Action en cours"; return; } + action.command = ["python3", "-B", backend, kind, id]; + action.running = true; + } + Process { + id: bridge + command: ["python3", "-B", service.backend, "serve"] + stdinEnabled: true + running: true + stdout: SplitParser { + onRead: data => { + try { + const state = JSON.parse(data); + service.error = state.error || ""; + if (!state.ids) return; + service.ids = state.ids; + service.apps = Model.catalog(state.apps); + service.iconTheme = state.theme; + service.ready = true; + service.available = true; + // WHY: an existing empty file means intentionally empty. + // Seed only an absent file; backend repeats that check under lock. + if (!state.exists) service.mutate("seed", Model.initialFavorites(service.apps)); + } catch (_) { service.error = "Réponse des favoris invalide"; } + } + } + onExited: { service.available = false; reconnect.start(); } + } + Timer { id: reconnect; interval: 1500; onTriggered: bridge.running = true } + Process { + id: action + stdout: StdioCollector { id: actionReply; waitForEnd: true } + onExited: (code, status) => { + if (code !== 0) { + try { service.error = JSON.parse(actionReply.text).error || "Action refusée"; } + catch (_) { service.error = "Action refusée"; } + } + } + } + // CONTRACT: one native event subscription for all outputs. Reconcile on + // connect and after bursts; dirty preserves events received during a query. + property bool dirty: false + function refreshWindows() { dirty = true; if (!snapshot.running) settle.restart(); } + Process { + id: subscription + command: ["swaymsg", "-m", "-t", "subscribe", '["window","workspace","output"]'] + running: true + stdout: SplitParser { onRead: data => service.refreshWindows() } + onStarted: service.refreshWindows() + onExited: { service.windows = []; reconnectEvents.restart(); } + } + // WHY: the native I3 listener in 0.3.2 did not resubscribe after a forced + // socket loss in the private-compositor test. This one owned process has + // a single restart deadline; there is no steady-state window polling. + Timer { id: reconnectEvents; interval: 1500; onTriggered: subscription.running = true } + Timer { id: settle; interval: 60; onTriggered: { service.dirty = false; snapshot.running = true; } } + Timer { id: retrySnapshot; interval: 1500; onTriggered: service.refreshWindows() } + Process { + id: snapshot + command: ["python3", "-B", service.backend, "windows"] + stdout: StdioCollector { id: snapshotReply; waitForEnd: true } + onExited: (code, status) => { + if (code === 0) { + try { service.windows = JSON.parse(snapshotReply.text).windows; } + catch (_) { service.windows = []; } + } else { service.windows = []; retrySnapshot.restart(); } + if (service.dirty) settle.restart(); + } + } + Component.onCompleted: refreshWindows() +} diff --git a/quickshell/.config/quickshell/labfy-sway/applications/WindowModel.js b/quickshell/.config/quickshell/labfy-sway/applications/WindowModel.js new file mode 100644 index 000000000..14532a3b7 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/WindowModel.js @@ -0,0 +1,35 @@ +.pragma library + +function match(apps, window) { + // CONTRACT: declared identities only, never titles. Exact desktop stem + // wins; an ambiguous executable/WM_CLASS alias must remain unknown. + const keys = [window.appId, window.wmClass, window.instance] + .filter(Boolean).map(value => value.toLowerCase()); + for (const key of keys) { + const exact = apps.filter(app => app.id.slice(0, -8).toLowerCase() === key); + if (exact.length === 1) return exact[0]; + const aliases = apps.filter(app => (app.aliases || []).includes(key)); + if (aliases.length === 1) return aliases[0]; + } + return null; +} + +function entries(apps, ids, windows) { + const byId = new Map(apps.map(app => [app.id, app])); + const result = ids.map(id => Object.assign({}, byId.get(id) || { + id: id, name: id.replace(/\.desktop$/, "") + " (indisponible)", + iconSource: "", newInstance: false, missing: true + }, {pinned: true, windows: []})); + const groups = new Map(result.map(entry => [entry.id, entry])); + for (const window of windows) { + const app = match(apps, window); + const id = app ? app.id : "window:" + (window.appId || window.wmClass || window.id); + if (!groups.has(id)) { + const entry = Object.assign({}, app || {id: id, name: window.appId || window.wmClass || "Application", + iconSource: "", missing: true, newInstance: false}, {pinned: false, windows: []}); + groups.set(id, entry); result.push(entry); + } + groups.get(id).windows.push(window); + } + return result; +} diff --git a/quickshell/.config/quickshell/labfy-sway/applications/backend.py b/quickshell/.config/quickshell/labfy-sway/applications/backend.py index e0c640791..331a5f6c3 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/applications/backend.py @@ -57,18 +57,31 @@ def write_favorites(path, action, ids): directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) try: fcntl.flock(directory, fcntl.LOCK_EX) + if (action in ("add", "remove", "toggle") and (len(ids) != 1 or not valid_id(ids[0]))) or ( + action == "move" and (len(ids) != 2 or not valid_id(ids[0]) + or (ids[1] and not valid_id(ids[1])))): + raise ValueError("Identifiant de favori invalide") exists, current = read_favorites(path) if action == "seed": if exists: return current current = list(dict.fromkeys(ids))[:MAX_FAVORITES] - elif action == "add": + elif action == "add" or (action == "toggle" and ids[0] not in current): if ids[0] not in current: if len(current) >= MAX_FAVORITES: raise ValueError("Trop de favoris") current.append(ids[0]) - elif action == "remove": + elif action in ("remove", "toggle"): current = [item for item in current if item != ids[0]] + elif action == "move": + # CONTRACT: move one existing ID before a live anchor (or to the + # end). Never replace a client's stale whole list: concurrent adds + # and removes survive. A vanished source/anchor rejects atomically. + if len(ids) != 2 or ids[0] not in current or (ids[1] and ids[1] not in current): + raise ValueError("Favori déplacé ou cible indisponible") + if ids[0] != ids[1]: + current.remove(ids[0]) + current.insert(current.index(ids[1]) if ids[1] else len(current), ids[0]) else: raise ValueError("Action de favoris invalide") if any(not valid_id(item) for item in current): @@ -155,10 +168,22 @@ def main(argv): if len(ids) > 4 or any(not valid_id(item) for item in ids): raise ValueError("Favoris initiaux invalides") result = {"ids": write_favorites(state_path(), "seed", ids)} - elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"]): + elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"], ["favorites", "toggle"]): if not valid_id(argv[2]): raise ValueError("Identifiant de favori invalide") result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])} + elif len(argv) == 4 and argv[:2] == ["favorites", "move"]: + result = {"ids": write_favorites(state_path(), "move", argv[2:])} + elif argv == ["serve"]: + from shared import serve + return serve() + elif argv == ["windows"]: + from shared import windows, sway_request + result = {"windows": windows(sway_request(4, ""))} + elif len(argv) == 2 and argv[0] == "focus": + from shared import focus + focus(argv[1]) + result = {"ok": True} elif len(argv) == 2 and argv[0] == "launch": launch(argv[1]) result = {"ok": True} @@ -166,7 +191,7 @@ def main(argv): raise ValueError("Commande invalide") print(json.dumps(result, ensure_ascii=False)) return 0 - except (OSError, ValueError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error: + except (OSError, ValueError, KeyError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error: print(json.dumps({"error": str(error)}, ensure_ascii=False)) return 1 diff --git a/quickshell/.config/quickshell/labfy-sway/applications/qmldir b/quickshell/.config/quickshell/labfy-sway/applications/qmldir new file mode 100644 index 000000000..edfcef465 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/qmldir @@ -0,0 +1,3 @@ +singleton FavoritesService 1.0 FavoritesService.qml +ApplicationsMenu 1.0 ApplicationsMenu.qml +ApplicationIcon 1.0 ApplicationIcon.qml diff --git a/quickshell/.config/quickshell/labfy-sway/applications/shared.py b/quickshell/.config/quickshell/labfy-sway/applications/shared.py new file mode 100644 index 000000000..ee6be78c9 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/shared.py @@ -0,0 +1,196 @@ +"""Shared application catalog, private icon theme and bounded Sway requests. + +No session/restore state is consulted or changed. The only persistent write +remains backend.write_favorites, under its existing directory transaction lock. +""" +import json +import os +from pathlib import Path +import socket +import struct +import sys +import time + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gio, GioUnix, GLib, Gtk +from backend import state_path, read_favorites, write_favorites, valid_id + +# CONTRACT: private GtkIconTheme instance, never Gtk settings or desktop-wide +# theme changes. GTK implements theme inheritance and absolute GIcon lookup. +ICON_THEME = 'Tela-circle-purple-dark' +# CONTRACT: middle-click is conservative. These terminal launchers create a +# new toplevel per invocation. Unknown and single-instance GUI applications +# retain their ordinary Open action without promising another instance. +NEW_INSTANCE_IDS = frozenset(('kitty.desktop', 'foot.desktop', 'Alacritty.desktop', + 'org.wezfurlong.wezterm.desktop')) + + +class IconResolver: + def __init__(self, theme=ICON_THEME): + self.theme = Gtk.IconTheme.new() + self.theme.set_custom_theme(theme) + + def resolve(self, icon): + if icon: + info = self.theme.lookup_by_gicon(icon, 48, Gtk.IconLookupFlags.FORCE_SIZE) + if info and Path(info.get_filename()).is_file(): + return Path(info.get_filename()).as_uri() + info = self.theme.lookup_icon('application-x-executable', 48, 0) + return Path(info.get_filename()).as_uri() if info and Path(info.get_filename()).is_file() else '' + + +def catalog(): + resolver = IconResolver() + result = [] + for app in Gio.AppInfo.get_all(): + identifier = app.get_id() + if not valid_id(identifier) or not app.should_show() or not isinstance(app, GioUnix.DesktopAppInfo): + continue + icon = app.get_icon() + # WHY: StartupWMClass is the desktop author's declared window identity. + # Filename stem and executable basename cover standard Wayland app_ids; + # ambiguous aliases are deliberately left unmatched by the UI model. + aliases = [identifier[:-8], app.get_startup_wm_class(), + os.path.basename(app.get_executable() or '')] + result.append(dict(id=identifier, name=app.get_display_name(), + genericName=app.get_generic_name() or '', + keywords=list(app.get_keywords() or []), + categories=(app.get_categories() or '').split(';'), + icon=icon.to_string() if icon else '', iconSource=resolver.resolve(icon), + aliases=sorted({v.casefold() for v in aliases if v}), + # D-Bus activation may only activate an existing window; + # do not promise a new instance for these applications. + newInstance=identifier in NEW_INSTANCE_IDS and not app.get_boolean('DBusActivatable'))) + return sorted(result, key=lambda a: a['id']) + + +def sway_request(kind, payload): + # CONTRACT: direct IPC, explicit little-endian widths, bounded reply and + # timeout. No shell, no inherited process, no background focus operations. + data = payload.encode() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + deadline = time.monotonic() + 2 + sock.settimeout(2) + sock.connect(os.environ['SWAYSOCK']) + sock.sendall(b'i3-ipc' + struct.pack(' 16 * 1024 * 1024: + raise ValueError('Réponse IPC Sway invalide') + return json.loads(receive(length)) + + +def windows(tree): + result = [] + pending = [(tree, '')] + while pending: + node, workspace = pending.pop() + if node.get('type') == 'workspace': + workspace = node.get('name', '') + pending.extend((child, workspace) for child in + node.get('nodes', []) + node.get('floating_nodes', [])) + props = node.get('window_properties') or {} + if not (node.get('app_id') or node.get('window')): + continue + result.append(dict(id=str(node['id']), title=node.get('name') or 'Fenêtre', + appId=node.get('app_id') or '', wmClass=props.get('class') or '', + instance=props.get('instance') or '', workspace=workspace, + focused=bool(node.get('focused')))) + return sorted(result, key=lambda w: int(w['id'])) + + +def focus(identifier): + if not isinstance(identifier, str) or not identifier.isascii() or not identifier.isdigit(): + raise ValueError('Identifiant de fenêtre invalide') + target = next((w for w in windows(sway_request(4, '')) if w['id'] == identifier), None) + if not target: + raise ValueError('Fenêtre fermée') + command = f'[con_id={int(identifier)}] ' + if target['workspace'] == '__i3_scratch': + replies = sway_request(0, command + 'scratchpad show') + if not all(reply.get('success') for reply in replies): + raise ValueError('Scratchpad indisponible') + if not all(reply.get('success') for reply in sway_request(0, command + 'focus')): + raise ValueError('Focus refusé') + + +def serve(): + """Single stream serializes UI mutations and disk notifications. + + INVARIANT: every published list is re-read from disk. A rejected mutation + publishes the unchanged durable state plus an error, never an optimistic + list. File monitoring survives atomic replacement by watching the directory. + """ + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + apps = catalog() + def publish(error=''): + try: + exists, ids = read_favorites(path) + print(json.dumps(dict(ids=ids, exists=exists, apps=apps, + theme=ICON_THEME, error=error)), flush=True) + except (OSError, ValueError) as exc: + print(json.dumps(dict(error=str(exc))), flush=True) + return False + monitor = Gio.File.new_for_path(str(path.parent)).monitor_directory(Gio.FileMonitorFlags.NONE, None) + pending = [0] + def changed(*_args): + if not pending[0]: + pending[0] = GLib.timeout_add(35, refresh) + def refresh(): + pending[0] = 0 + return publish() + monitor.connect('changed', changed) + app_monitor = Gio.AppInfoMonitor.get() + def apps_changed(*_args): + nonlocal apps + apps = catalog() + publish() + app_monitor.connect('changed', apps_changed) + loop = GLib.MainLoop() + # CONTRACT: newline JSON input is bounded before parsing. GLib owns the + # single main thread; EOF ends this helper with its QuickShell owner. + buffer = bytearray() + def request(fd, condition): + chunk = os.read(fd, 4096) + if not chunk: + loop.quit() + return False + buffer.extend(chunk) + if len(buffer) > 16384: + loop.quit() + return False + while b'\n' in buffer: + line, _, rest = buffer.partition(b'\n') + buffer[:] = rest + try: + data = json.loads(line) + action = data.get('action') + ids = data.get('ids') + if action not in ('add', 'remove', 'toggle', 'move', 'seed') or not isinstance(ids, list): + raise ValueError('Commande de favoris invalide') + if action == 'seed' and (len(ids) > 4 or any(not valid_id(i) for i in ids)): + raise ValueError('Favoris initiaux invalides') + write_favorites(path, action, ids) + publish() + except (OSError, ValueError, TypeError) as exc: + publish(str(exc)) + return True + GLib.io_add_watch(sys.stdin.fileno(), GLib.IO_IN | GLib.IO_HUP, request) + publish() + loop.run() + monitor.cancel() + return 0 diff --git a/quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js b/quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js new file mode 100644 index 000000000..8bd958885 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js @@ -0,0 +1,21 @@ +.pragma library + +// CONTRACT: the sole deadline belongs to arming (edge dwell) or waiting +// (exit grace). Holding any interaction cancels it. No animation sends IPC. +function next(state, event, input) { + if (input.suppressed) return "hidden"; + if (input.held) return "held"; + if (input.body) return "shown"; + if (input.edge) { + if (state === "hidden") return "arming"; + if (state === "arming") return event === "deadline" ? "shown" : "arming"; + return "shown"; + } + if (state === "hidden" || state === "arming") return "hidden"; + if (state === "waiting") return event === "deadline" ? "hidden" : "waiting"; + return "waiting"; +} + +function influence(distance) { + return Math.max(0, 1 - Math.abs(distance) / 115); +} diff --git a/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml b/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml new file mode 100644 index 000000000..d25fd821d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import "../applications" +import "../theme" +import "AutoHide.js" as AutoHide + +PanelWindow { + id: dock + required property var modelData + property bool suppressed: false + screen: modelData + anchors.bottom: true + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + aboveWindows: true + WlrLayershell.layer: WlrLayer.Top + // CONTRACT: the dock and its in-surface menus never accept keyboard focus. + // Layer surfaces are absent from get_tree, hence Session V2 snapshots. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "labfy-sway-dock" + color: "transparent" + implicitWidth: Math.max(96, Math.min(screen.width - 24, items.width + 28)) + implicitHeight: 360 + property string phase: "hidden" + readonly property bool revealed: phase === "shown" || phase === "waiting" || phase === "held" + property bool pressed: false + property string draggingId: "" + property var contextEntry: null + property real contextX: 0 + property real pointerX: -1000 + readonly property bool held: pressed || draggingId !== "" || contextEntry !== null + readonly property var entries: FavoritesService.dockEntries + readonly property int pinnedCount: FavoritesService.ids.length + property alias contentItemRow: items + property alias surfaceBody: body + property alias contextCard: context + property alias edgeArea: sensor + // INVARIANT: only the bottom activation strip receives input while hidden. + // Visible input follows the animated body, with a bridge to the edge. + mask: Region { + item: sensor + Region { item: body; intersection: dock.revealed ? Intersection.Combine : Intersection.Subtract } + Region { item: context; intersection: context.visible ? Intersection.Combine : Intersection.Subtract } + } + function reconcile(event) { + phase = AutoHide.next(phase, event || "input", { + suppressed: suppressed, held: held, + body: bodyHover.hovered || contextHover.hovered, + edge: sensor.containsMouse + }); + } + onHeldChanged: reconcile() + onSuppressedChanged: { contextEntry = null; draggingId = ""; pressed = false; reconcile(); } + onEntriesChanged: { + if (contextEntry) { + contextEntry = entries.find(entry => entry.id === contextEntry.id) || null; + } + } + Timer { + id: deadline + interval: dock.phase === "arming" ? 180 : 450 + running: dock.phase === "arming" || dock.phase === "waiting" + onTriggered: dock.reconcile("deadline") + } + function activate(entry) { + if (entry.windows.length === 1) FavoritesService.focusWindow(entry.windows[0].id); + else if (entry.windows.length > 1) openContext(entry, width / 2); + else if (!entry.missing) FavoritesService.launch(entry.id); + } + function openContext(entry, x) { + contextX = x; + contextEntry = entry; + } + function closeContext() { contextEntry = null; } + MouseArea { + id: sensor + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: dock.revealed ? dock.width : Math.min(360, dock.width) + height: 4 + hoverEnabled: true + onEntered: dock.reconcile() + onExited: dock.reconcile() + onClicked: dock.closeContext() + } + Item { + id: body + width: parent.width + height: 110 + y: dock.revealed ? dock.height - height : dock.height + 4 + Behavior on y { NumberAnimation { duration: 210; easing.type: Easing.OutCubic } } + HoverHandler { + id: bodyHover + onHoveredChanged: { dock.reconcile(); if (!hovered) dock.pointerX = -1000; } + onPointChanged: dock.pointerX = point.position.x + } + Rectangle { + // Discreet local shadow, no screen capture or blur sampling. + x: 2; y: 34; width: parent.width - 4; height: 70 + radius: 26; color: Theme.shadow + } + Rectangle { + x: 0; y: 29; width: parent.width; height: 70 + radius: 25 + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, Theme.highContrast ? 1 : 0.89) + border.color: Theme.strongBorder; border.width: 1 + } + Flickable { + id: viewport + x: 12; y: 0; width: parent.width - 24; height: 105 + contentWidth: items.width; contentHeight: height + clip: true + interactive: dock.draggingId === "" + boundsBehavior: Flickable.StopAtBounds + ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded } + Row { + id: items + height: viewport.height + spacing: 3 + move: Transition { NumberAnimation { properties: "x"; duration: 110; easing.type: Easing.OutCubic } } + Repeater { + id: icons + model: dock.entries + delegate: Item { + id: tile + required property var modelData + required property int index + readonly property bool separator: index === dock.pinnedCount && dock.pinnedCount > 0 + readonly property real center: viewport.x + x + width / 2 - viewport.contentX + // WHY: measuring against the animated x/width feeds + // scale back into its own binding. Stable resting slots + // keep magnification deterministic and free of loops. + readonly property real baseCenter: viewport.x + index * 57 + 27 + + (index >= dock.pinnedCount && dock.pinnedCount > 0 ? 13 : 0) - viewport.contentX + readonly property real growth: dock.revealed && bodyHover.hovered + ? AutoHide.influence(baseCenter - dock.pointerX) : 0 + width: 54 + growth * 19 + (separator ? 13 : 0) + height: 105 + Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } } + Rectangle { + visible: tile.separator + x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder + } + ApplicationIcon { + id: icon + width: 46 + tile.growth * 21; height: width + x: (parent.width - width + (tile.separator ? 12 : 0)) / 2 + y: 83 - height + iconSource: tile.modelData.iconSource || "" + opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1 + Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } } + } + Rectangle { + anchors.horizontalCenter: icon.horizontalCenter + y: 90; width: 4; height: 4; radius: 2 + color: Theme.lavender; visible: tile.modelData.windows.length > 0 + } + MouseArea { + id: pointer + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + preventStealing: tile.modelData.pinned + property real startX: 0 + property real startY: 0 + property bool moved: false + onPressed: mouse => { + dock.pressed = true; startX = mouse.x; startY = mouse.y; moved = false; + } + onPositionChanged: mouse => { + if (pressed && pressedButtons === Qt.LeftButton && tile.modelData.pinned + && Math.hypot(mouse.x - startX, mouse.y - startY) > 8) { + moved = true; dock.draggingId = tile.modelData.id; + } + } + onReleased: mouse => { + if (moved) { + const p = mapToItem(items, mouse.x, mouse.y); + let before = ""; + for (let i = 0; i < icons.count; ++i) { + const candidate = icons.itemAt(i); + if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId + && p.x < candidate.x + candidate.width / 2) { + before = candidate.modelData.id; break; + } + } + FavoritesService.move(dock.draggingId, before); + } else if (mouse.button === Qt.RightButton) { + dock.openContext(tile.modelData, tile.center); + } else if (mouse.button === Qt.MiddleButton) { + if (!tile.modelData.missing && tile.modelData.newInstance) + FavoritesService.launch(tile.modelData.id); + } else { + dock.closeContext(); dock.activate(tile.modelData); + } + dock.draggingId = ""; dock.pressed = false; + } + onCanceled: { dock.draggingId = ""; dock.pressed = false; } + ToolTip.visible: containsMouse && !dock.held + ToolTip.text: tile.modelData.name + ToolTip.delay: 600 + } + } + } + } + } + } + Rectangle { + id: context + visible: dock.contextEntry !== null + width: Math.min(330, dock.width) + height: Math.min(230, contextContent.height + 16) + x: Math.max(0, Math.min(dock.width - width, dock.contextX - width / 2)) + y: dock.height - 116 - height + radius: 12; color: Theme.popupBackground + border.color: Theme.strongBorder + HoverHandler { id: contextHover; onHoveredChanged: dock.reconcile() } + Flickable { + anchors.fill: parent; anchors.margins: 8 + contentHeight: contextContent.height; clip: true + Column { + id: contextContent + width: parent.width + Text { + width: parent.width; height: 28 + text: dock.contextEntry ? dock.contextEntry.name : "" + color: Theme.lavender; elide: Text.ElideRight; font.bold: true + } + Repeater { + model: dock.contextEntry ? dock.contextEntry.windows : [] + delegate: DockAction { + required property var modelData + width: contextContent.width + text: modelData.title + " · " + modelData.workspace + onTriggered: { FavoritesService.focusWindow(modelData.id); dock.closeContext(); } + } + } + DockAction { + width: parent.width + visible: !!dock.contextEntry && !dock.contextEntry.missing + text: "Ouvrir" + onTriggered: { FavoritesService.launch(dock.contextEntry.id); dock.closeContext(); } + } + DockAction { + width: parent.width + visible: !!dock.contextEntry && (dock.contextEntry.pinned || !dock.contextEntry.missing) + objectName: "dock-pin-action" + text: dock.contextEntry && dock.contextEntry.pinned ? "Désépingler" : "Épingler" + onTriggered: { FavoritesService.toggle(dock.contextEntry.id); dock.closeContext(); } + } + DockAction { width: parent.width; text: "Fermer le menu"; onTriggered: dock.closeContext() } + } + } + } + Text { + visible: dock.revealed && FavoritesService.error !== "" && !context.visible + anchors.horizontalCenter: parent.horizontalCenter + y: dock.height - 140 + text: FavoritesService.error; color: Theme.danger; font.pixelSize: 12 + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml b/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml new file mode 100644 index 000000000..6d5bc649c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml @@ -0,0 +1,13 @@ +import QtQuick +import "../theme" +Rectangle { + id: action + property string text: "" + signal triggered() + height: visible ? 30 : 0 + radius: 5 + color: pointer.containsMouse ? Theme.buttonHover : "transparent" + Text { anchors.fill: parent; anchors.margins: 5; text: action.text; color: Theme.foreground + elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter; font.pixelSize: 12 } + MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: action.triggered() } +} diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index c49e30bfc..712bbfbbd 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -10,6 +10,8 @@ import "theme" import "polkit" import "scratchpad" import "osd" +import "applications" +import "dock" ShellRoot { id: shell @@ -242,6 +244,12 @@ ShellRoot { return AppearanceController.acceptPublishedNightLight(payload); } } + // CONTRACT: shell-owned singleton remains alive while the menu is closed. + readonly property bool applicationsReady: FavoritesService.ready + Variants { + model: Quickshell.screens + Dock { suppressed: shell.authenticationActive || shell.applicationsOutput !== "" } + } // Une barre par écran, y compris si les sorties changent pendant la session. // CONTRACT: un seul serveur D-Bus pour toutes les sorties. NotificationService { id: rootNotificationService } diff --git a/sway/.config/sway/swayfx b/sway/.config/sway/swayfx index b8081104c..033e54f25 100644 --- a/sway/.config/sway/swayfx +++ b/sway/.config/sway/swayfx @@ -26,3 +26,13 @@ layer_effects "labfy-sway-bar" { shadows enable corner_radius 4 } + +# CONTRACT: only the dock's layer surface receives blur; no window rule or +# focus/restore policy is changed. Its transparent input strip stays clear. +layer_effects "labfy-sway-dock" { + blur $bar_blur + blur_xray disable + blur_ignore_transparent enable + shadows disable + corner_radius 25 +} diff --git a/tests/dock/headless.py b/tests/dock/headless.py new file mode 100644 index 000000000..fb4833580 --- /dev/null +++ b/tests/dock/headless.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Private Sway only; production Dock/Menu, real Python bridge and input paths.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures')) +from headless import Lab +from ipc_proxy import IpcProxy + +SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway' +ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela' +NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop', + 'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop', + 'org.kde.digikam.desktop', 'AppFlowy.desktop'] + +class DockLab(Lab): + def __init__(self): + super().__init__() + self.config=self.directory/'dock-sway.conf' + base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text() + effects=(REPO/'sway/.config/sway/swayfx').read_text().split('# CONTRACT: only the dock')[1] + effects=effects[effects.index('layer_effects'):] + self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects) + def start(self, argv): + if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'): + argv=[*argv[:-1],str(self.config)] + return super().start(argv) + +with DockLab() as lab: + proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket) + lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock') + path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json' + path.parent.mkdir(parents=True) + path.write_text(json.dumps(dict(version=1, ids=NINE))) + qml = lab.directory / 'fixture.qml' + qml.write_text('''//@ pragma UseQApplication +import QtQuick +import QtTest +import Quickshell +import Quickshell.Io +import "SOURCE/applications" as A +import "SOURCE/dock" as D +ShellRoot { + D.Dock { id: dock; modelData: Quickshell.screens[0] } + PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" } + LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } } + TestCase { id: input; name: "DockInput"; when: false } + IpcHandler { + target: "dockTest" + function state(): string { + return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids, + error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed, + entries:dock.entries, theme:A.FavoritesService.iconTheme, + menu:menu.active ? menu.item.favoriteIds : null, + searchFocused:menu.active && menu.item.searchFocused, + navFocused:menu.active && menu.item.navigationFocused, + gridFocused:menu.active && menu.item.gridFocused, + screenWidth:dock.screen.width, + bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX, + + tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0, + context:!!dock.contextEntry}); + } + function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); } + function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); } + function leave(): void { input.mouseMove(dock.contentItem, 1, 1); } + function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); } + function dockToggle(id:string): void { A.FavoritesService.toggle(id); } + function move(id:string, before:string): void { A.FavoritesService.move(id,before); } + function closeMenu(): void { menu.active=false; } + function tab(): void { input.keyClick(Qt.Key_Tab); } + function clickPin(): void { + const action=input.findChild(dock.contextCard,"dock-pin-action"); + input.mouseClick(action,action.width/2,action.height/2); + } + function rightClick(): void { + input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton); + } + function dragFirstAfterSecond(): void { + const first=dock.contentItemRow.children[0]; + input.mousePress(first,27,65,Qt.LeftButton); + input.mouseMove(first,125,65,100); + input.mouseRelease(first,125,65,Qt.LeftButton); + } + function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); } + function context(): void { dock.openContext(dock.entries[0],dock.width/2); } + function closeContext(): void { dock.closeContext(); } + function suppress(value:bool): void { dock.suppressed=value; } + function focus(id:string): void { A.FavoritesService.focusWindow(id); } + } +} +'''.replace('SOURCE', SOURCE.as_uri())) + lab.env['QT_QPA_PLATFORM'] = 'wayland' + process = lab.start(['quickshell', '-p', str(qml)]) + def call(name, *args): + r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)], + env=lab.env, text=True, capture_output=True, timeout=8) + if r.returncode: + logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log')) + raise AssertionError((name,r.stdout,r.stderr,logs)) + return json.loads(r.stdout) if r.stdout.strip() else None + def wait(predicate, seconds=8): + end = time.monotonic()+seconds + while time.monotonic()54, zoom + call('leave'); wait(lambda s:s['phase']=='hidden') + call('edge'); wait(lambda s:s['revealed']); time.sleep(.3) + call('rightClick'); wait(lambda s:s['phase']=='held'); time.sleep(.7) + assert call('state')['context'] + call('clickPin'); wait(lambda s:'firefox.desktop' not in s['ids']) + call('dockToggle','firefox.desktop'); wait(lambda s:'firefox.desktop' in s['ids']) + call('move','firefox.desktop','kitty.desktop'); wait(lambda s:s['ids']==NINE) + call('leave'); wait(lambda s:s['phase']=='hidden') + # Refused move leaves both interfaces at the same durable list. + call('move','kitty.desktop','removed.desktop') + wait(lambda s:bool(s['error'])) + assert call('state')['ids']==NINE + call('menuToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' in s['ids'] and s['menu']==s['ids']) + # Gio catalog monitoring replaces the placeholder without dropping its ID. + desktop=Path(lab.env['HOME'])/'.local/share/applications/fixture.desktop' + desktop.parent.mkdir(parents=True,exist_ok=True) + desktop.write_text('[Desktop Entry]\nType=Application\nName=Installed Fixture\nExec=/usr/bin/true\nIcon=firefox\n') + wait(lambda s:any(e['id']=='fixture.desktop' and not e.get('missing') for e in s['entries'])) + desktop.unlink() + wait(lambda s:any(e['id']=='fixture.desktop' and e.get('missing') for e in s['entries'])) + # Keep the virtual keyboard alive: the headless seat has no physical + # keyboard, so destroying wtype would remove keyboard capabilities and + # deactivate the QML focus window before we could sample its focus item. + keyboard=lab.start(['wtype','-s','300','-k','Tab','-s','1500','-k','Tab', + '-s','1500','-k','Tab','-s','1500']) + wait(lambda s:s['navFocused']) + wait(lambda s:s['gridFocused']) + wait(lambda s:s['searchFocused']) + keyboard.wait(timeout=8) + # An independent legacy CLI writer is observed by menu and dock live. + subprocess.run(['python','-B',str(SOURCE/'applications/backend.py'),'favorites','add','external.desktop'], + env=lab.env,check=True,capture_output=True) + wait(lambda s:'external.desktop' in s['ids'] and s['menu']==s['ids']) + call('dockToggle','external.desktop'); wait(lambda s:'external.desktop' not in s['ids']) + call('dockToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' not in s['ids'] and s['menu']==s['ids']) + call('move','kitty.desktop','firefox.desktop'); reordered=wait(lambda s:s['ids'][0]=='kitty.desktop') + assert json.loads(path.read_text())['ids']==reordered['ids'] + call('closeMenu') + call('edge'); wait(lambda s:s['revealed']); time.sleep(.3) + call('dragFirstAfterSecond') + dragged=wait(lambda s:s['ids'][0]=='firefox.desktop') + assert dragged['ids'][1]=='kitty.desktop',dragged + call('leave'); wait(lambda s:s['phase']=='hidden') + # No focus changes while the pointer reveals, zooms, hides, or opens menus. + win=lab.kitty('kitty') + time.sleep(.5) + focused=lambda: next(n['id'] for n in lab.windows() if n.get('focused')) + assert focused()==win + for action in ('edge','body','context','leave','closeContext'): + call(action); time.sleep(.25); assert focused()==win, action + call('suppress','true'); assert call('state')['phase']=='hidden' + call('suppress','false') + wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries'])) + proxy.disconnect() + time.sleep(2) + second=lab.kitty('kitty') + wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==2 for e in s['entries'])) + lab.command(f'[con_id={win}] floating enable') + lab.command(f'[con_id={win}] move scratchpad') + call('focus',str(win)); time.sleep(.3); assert focused()==win + lab.command(f'[con_id={second}] kill') + wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries'])) + # Firefox runs with the private HOME/profile and no inherited session bus. + profile=lab.directory/'firefox-profile'; profile.mkdir() + firefox=lab.start(['firefox','--no-remote','--profile',str(profile),'--new-window','about:blank']) + firefox_state=wait(lambda s:any(e['id']=='firefox.desktop' and e['windows'] for e in s['entries']),20) + firewin=next(e for e in firefox_state['entries'] if e['id']=='firefox.desktop')['windows'][0]['id'] + call('closeContext'); call('edge'); wait(lambda s:s['revealed']); time.sleep(.3) + call('activateFirst'); time.sleep(.3) + assert str(focused())==firewin + for action in ('edge','body','leave'): + call(action); time.sleep(.25); assert str(focused())==firewin + # Layer-shell surfaces cannot enter the Session V2 toplevel inventory. + assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows()) + lab.command('output HEADLESS-1 mode 800x600') + resized=wait(lambda s:s['screenWidth']==800) + assert resized['width']<=776,resized + lab.command('output HEADLESS-1 mode 1280x720') + wait(lambda s:s['screenWidth']==1280) + # Existing empty state must stay empty, including a bridge restart. + for identifier in list(call('state')['ids']): call('dockToggle',identifier) + wait(lambda s:not s['ids']) + process.terminate(); process.wait(timeout=5) + process=lab.start(['quickshell','-p',str(qml)]) + time.sleep(1) + assert wait(lambda s:s['ready'])['ids']==[] + (ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial, + reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder', + 'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus', + 'window close','no hover focus','real pointer drag','zoom growth','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur rule']),indent=2)+'\n') + proxy.close() + print('Dock headless: PASS',lab.directory) diff --git a/tests/dock/ipc_proxy.py b/tests/dock/ipc_proxy.py new file mode 100644 index 000000000..18676864a --- /dev/null +++ b/tests/dock/ipc_proxy.py @@ -0,0 +1,54 @@ +"""Test-only disconnectable proxy; targets only a fixture-owned Sway socket.""" +import select +import socket +import threading + + +class IpcProxy: + def __init__(self, path, target): + self.target = target + self.socket = socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) + self.socket.bind(str(path)); self.socket.listen(8); self.socket.settimeout(.2) + self.stopped = threading.Event() + self.lock = threading.Lock() + self.connections = set() + self.thread = threading.Thread(target=self.accept,daemon=True) + self.thread.start() + + def accept(self): + while not self.stopped.is_set(): + try: client,_ = self.socket.accept() + except socket.timeout: continue + except OSError: break + upstream=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) + upstream.settimeout(2) + try: upstream.connect(self.target) + except OSError: client.close(); upstream.close(); continue + with self.lock: + if len(self.connections)>=16: + client.close(); upstream.close(); continue + self.connections.add(client); self.connections.add(upstream) + threading.Thread(target=self.forward,args=(client,upstream),daemon=True).start() + + def forward(self, client, upstream): + try: + while not self.stopped.is_set(): + ready,_,_=select.select([client,upstream],[],[],.2) + for source in ready: + data=source.recv(65536) + if not data: return + (upstream if source is client else client).sendall(data) + except (OSError,ValueError): pass + finally: + with self.lock: + self.connections.discard(client); self.connections.discard(upstream) + client.close(); upstream.close() + + def disconnect(self): + with self.lock: + for connection in self.connections: + try: connection.shutdown(socket.SHUT_RDWR) + except OSError: pass + + def close(self): + self.stopped.set(); self.disconnect(); self.socket.close(); self.thread.join(2) diff --git a/tests/dock/session_compat.py b/tests/dock/session_compat.py new file mode 100644 index 000000000..40d5f210b --- /dev/null +++ b/tests/dock/session_compat.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Run existing production chooser/restore scenarios with the dock present. + +Only the test harness is extended in memory. No Session V2 source, journal, +keyring/PAM configuration or active graphical environment is changed. +""" +from pathlib import Path +import sys + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tests/session_v2')) +import keyring_ui +import phase3b_ui + +source=(ROOT/'quickshell/.config/quickshell/labfy-sway/dock').as_uri() +for module in (keyring_ui,phase3b_ui): + module.HARNESS=module.HARNESS.replace('import Quickshell\n', + 'import Quickshell\nimport "'+source+'" as D\n') + module.HARNESS=module.HARNESS.replace('ShellRoot {','''ShellRoot { + D.Dock { id: compatibilityDock; modelData: Quickshell.screens[0] } + Timer { interval: 3500; running: true; onTriggered: { + if (compatibilityDock.phase !== "hidden") { + console.error("DOCK_COMPAT_FAIL_NOT_HIDDEN"); Qt.quit(); + } + } } +''') + module.main() + print(module.__name__+' WITH_DOCK PASS',flush=True) diff --git a/tests/dock/shell_load.py b/tests/dock/shell_load.py new file mode 100644 index 000000000..081c71539 --- /dev/null +++ b/tests/dock/shell_load.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Load the actual shell through its relative imports in a private Sway. + +The session bus is private; the system bus address deliberately points to a +missing fixture socket so no real Polkit agent or system service is contacted. +""" +from pathlib import Path +import sys +import time + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tests/session_v2/fixtures')) +from headless import Lab + +with Lab() as lab: + lab.env.update(DBUS_SYSTEM_BUS_ADDRESS='unix:path='+str(lab.directory/'no-system-bus'),QT_QPA_PLATFORM='wayland') + shell=ROOT/'quickshell/.config/quickshell/labfy-sway/shell.qml' + process=lab.start(['dbus-run-session','--','quickshell','-p',str(shell)]) + time.sleep(3) + log=(lab.directory/'child-1.log').read_text() + assert 'Configuration Loaded' in log and process.poll() is None,log + assert 'Binding loop' not in log,log + print('Full shell private-compositor load: PASS',lab.directory) diff --git a/tests/dock/test_models.js b/tests/dock/test_models.js new file mode 100644 index 000000000..903ed765f --- /dev/null +++ b/tests/dock/test_models.js @@ -0,0 +1,46 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +function load(file) { + const context=vm.createContext({}); + vm.runInContext(fs.readFileSync(path.join(__dirname,'../../quickshell/.config/quickshell/labfy-sway',file),'utf8') + .replace(/^\.pragma library\s*\n/,''),context); + return context; +} +const hide=load('dock/AutoHide.js'); +let phase='hidden'; +const input={suppressed:false,held:false,body:false,edge:false}; +const event=(name='input') => phase=hide.next(phase,name,input); +assert.equal(event(),'hidden'); +input.edge=true; assert.equal(event(),'arming'); +input.edge=false; assert.equal(event(),'hidden'); +input.edge=true; event(); assert.equal(event('deadline'),'shown'); +input.edge=false; assert.equal(event(),'waiting'); +input.body=true; assert.equal(event(),'shown'); +input.body=false; event(); input.held=true; assert.equal(event(),'held'); +assert.equal(event('deadline'),'held'); +input.held=false; assert.equal(event(),'waiting'); +assert.equal(event('deadline'),'hidden'); +input.held=true; event(); input.suppressed=true; assert.equal(event(),'hidden'); +assert.equal(hide.influence(0),1); +assert.ok(hide.influence(55)>hide.influence(100)); +assert.equal(hide.influence(115),0); +const model=load('applications/WindowModel.js'); +const apps=[{id:'kitty.desktop',aliases:['kitty'],iconSource:'file:///tela/kitty.svg'}, + {id:'org.app.desktop',aliases:['startup-class','executable']}, + {id:'org.other.desktop',aliases:['executable']}]; +assert.equal(model.match(apps,{appId:'kitty'}).id,'kitty.desktop'); +assert.equal(model.match(apps,{wmClass:'Startup-Class'}).id,'org.app.desktop'); +assert.equal(model.match(apps,{appId:'executable'}),null); +assert.equal(model.match(apps,{title:'kitty'}),null); +let entries=model.entries(apps,['absent.desktop','kitty.desktop'],[{id:'10',appId:'kitty'},{id:'11',appId:'kitty'}, + {id:'12',appId:'unknown'}]); +assert.equal(entries[0].missing,true); +assert.equal(entries[1].windows.length,2); +assert.equal(entries[1].iconSource,apps[0].iconSource); +assert.equal(entries[2].pinned,false); +assert.equal(model.entries(apps,[],[]).length,0); +assert.equal(model.entries(apps,[],[{id:'11',appId:'kitty'}])[0].pinned,false); +assert.equal(model.entries(apps,['kitty.desktop'],[])[0].windows.length,0); +console.log('Dock state and window models: PASS'); diff --git a/tests/dock/test_shared.py b/tests/dock/test_shared.py new file mode 100644 index 000000000..7e4a429e0 --- /dev/null +++ b/tests/dock/test_shared.py @@ -0,0 +1,109 @@ +"""Durable real backend operations and private toolkit icon resolution.""" +import concurrent.futures +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +APP = ROOT/'quickshell/.config/quickshell/labfy-sway/applications' +sys.path.insert(0, str(APP)) +import backend +import shared +from gi.repository import Gio + +NINE = ['firefox.desktop','kitty.desktop','yazi.desktop','nvim.desktop', + 'org.darktable.darktable.desktop','orca-slicer.desktop','org.freecad.FreeCAD.desktop', + 'org.kde.digikam.desktop','AppFlowy.desktop'] + + +class Persistence(unittest.TestCase): + def test_nine_move_missing_anchor_and_empty(self): + with tempfile.TemporaryDirectory() as temp: + path=Path(temp)/'labfy-sway/applications-favorites.json' + path.parent.mkdir() + path.write_text(json.dumps(dict(version=1,ids=NINE))) + self.assertEqual(backend.read_favorites(path)[1],NINE) + expected=[NINE[1],NINE[0]]+NINE[2:] + self.assertEqual(backend.write_favorites(path,'move',[NINE[1],NINE[0]]),expected) + before=path.read_bytes() + for pair in ([NINE[0],'absent.desktop'], ['absent.desktop',NINE[0]], [NINE[0],'../bad']): + with self.assertRaises(ValueError): backend.write_favorites(path,'move',pair) + self.assertEqual(before,path.read_bytes()) + for identifier in NINE: backend.write_favorites(path,'remove',[identifier]) + self.assertEqual(backend.write_favorites(path,'seed',NINE[:4]),[]) + self.assertEqual(backend.read_favorites(path),(True,[])) + + def test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list(self): + with tempfile.TemporaryDirectory() as temp: + path=Path(temp)/'favorites.json' + backend.write_favorites(path,'toggle',['kitty.desktop']) + self.assertEqual(backend.write_favorites(path,'toggle',['kitty.desktop']),[]) + + def test_real_concurrent_processes_keep_all_additions_during_moves(self): + with tempfile.TemporaryDirectory() as temp: + env=dict(os.environ,XDG_STATE_HOME=temp) + path=Path(temp)/'labfy-sway/applications-favorites.json' + backend.write_favorites(path,'seed',['a.desktop','b.desktop']) + def run(args): + result=subprocess.run([sys.executable,'-B',str(APP/'backend.py'),'favorites',*args], + env=env,capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + jobs=[['add',f'added{i}.desktop'] for i in range(24)] + jobs += [['move','b.desktop','a.desktop'] for _ in range(10)] + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + list(executor.map(run,jobs)) + ids=backend.read_favorites(path)[1] + self.assertEqual(ids[:2],['b.desktop','a.desktop']) + self.assertEqual(len(ids),26) + self.assertEqual(set(ids[2:]),{f'added{i}.desktop' for i in range(24)}) + + +class Icons(unittest.TestCase): + def test_tela_desktop_icon_and_absolute(self): + resolver=shared.IconResolver() + self.assertIn('/Tela-circle-purple-dark/',resolver.resolve(Gio.ThemedIcon.new('firefox'))) + with tempfile.TemporaryDirectory() as temp: + path=Path(temp)/'custom icon.svg' + path.write_text('') + self.assertEqual(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path(str(path)))),path.as_uri()) + self.assertTrue(resolver.resolve(Gio.ThemedIcon.new('labfy-icon-does-not-exist'))) + self.assertTrue(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path('/missing/icon.svg')))) + + def test_inheritance_from_a_private_test_theme(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp) + for name,parent in [('Child','Parent'),('Parent','hicolor')]: + directory=root/name; (directory/'48x48/apps').mkdir(parents=True) + (directory/'index.theme').write_text('[Icon Theme]\nName='+name+'\nInherits='+parent+ + '\nDirectories=48x48/apps\n[48x48/apps]\nSize=48\nType=Fixed\nContext=Applications\n') + icon=root/'Parent/48x48/apps/inherited.svg' + icon.write_text('') + resolver=shared.IconResolver('Child') + resolver.theme.set_search_path([temp]) + self.assertEqual(resolver.resolve(Gio.ThemedIcon.new('inherited')),icon.as_uri()) + + +class Sway(unittest.TestCase): + def test_wayland_xwayland_scratchpad_and_explicit_focus(self): + tree=dict(nodes=[dict(type='workspace',name='__i3_scratch',floating_nodes=[ + dict(id=42,window=3,window_properties={'class':'Fixture','instance':'fixture'},name='Arbitrary'), + dict(id=43,app_id='kitty',name='Completely different title')])]) + windows=shared.windows(tree) + self.assertEqual(windows[0]['wmClass'],'Fixture') + self.assertEqual(windows[1]['appId'],'kitty') + self.assertEqual(windows[0]['workspace'],'__i3_scratch') + with patch.object(shared,'sway_request',side_effect=[tree,[{'success':True}],[{'success':True}]]) as ipc: + shared.focus('42') + self.assertEqual([c.args for c in ipc.call_args_list],[(4,''),(0,'[con_id=42] scratchpad show'), + (0,'[con_id=42] focus')]) + with patch.object(shared,'sway_request') as ipc: + with self.assertRaises(ValueError): shared.focus('42] focus; exec bad') + ipc.assert_not_called() + +if __name__=='__main__': unittest.main()