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 000000000..0d5c96b47 Binary files /dev/null and b/docs/validation/quickshell-dock-tela/closed.png differ 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 000000000..f3be7ad3b Binary files /dev/null and b/docs/validation/quickshell-dock-tela/open.png differ 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 000000000..f22cb06b2 Binary files /dev/null and b/docs/validation/quickshell-dock-tela/zoom.png differ 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()